From 67a19e83c39b4273662c0adb12c4c63029645da4 Mon Sep 17 00:00:00 2001 From: MikeLippincott <1michaell2017@gmail.com> Date: Wed, 13 May 2026 12:59:59 -0600 Subject: [PATCH 1/2] integrate the contracts with the modules --- .gitignore | 1 + pyproject.toml | 1 + src/zedprofiler/IO/loading_classes.py | 14 +- src/zedprofiler/contracts.py | 153 +++---- .../featurization/colocalization.py | 134 +++++- src/zedprofiler/featurization/granularity.py | 66 ++- src/zedprofiler/featurization/intensity.py | 51 ++- src/zedprofiler/featurization/neighbors.py | 54 ++- src/zedprofiler/featurization/texture.py | 52 ++- .../featurization/volumesizeshape.py | 47 +- tests/IO/test_loading_classes.py | 70 ++- tests/featurization/test_colocalization.py | 128 ------ tests/featurization/test_granularity.py | 151 ------ tests/featurization/test_intensity.py | 143 ------ tests/featurization/test_neighbors.py | 257 ----------- tests/featurization/test_texture.py | 228 ---------- tests/featurization/test_volumesizeshape.py | 338 -------------- tests/test_contracts.py | 123 ++++- tests/test_integrations.py | 22 +- uv.lock | 428 ++++++++++++++++-- 20 files changed, 1007 insertions(+), 1454 deletions(-) delete mode 100644 tests/featurization/test_colocalization.py delete mode 100644 tests/featurization/test_granularity.py delete mode 100644 tests/featurization/test_intensity.py delete mode 100644 tests/featurization/test_neighbors.py delete mode 100644 tests/featurization/test_texture.py delete mode 100644 tests/featurization/test_volumesizeshape.py diff --git a/.gitignore b/.gitignore index 5e026de..a8911e2 100644 --- a/.gitignore +++ b/.gitignore @@ -174,3 +174,4 @@ cython_debug/ .pypirc data/* testing.ipynb +test_data/* diff --git a/pyproject.toml b/pyproject.toml index 9b9b352..ac97d6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "bioio-tifffile>=1.3", "fire>=0.7.1", "jinja2>=3.1.6", + "mahotas>=1.4.18", "matplotlib>=3.10.8", "pandas>=3.0.2", "pandera>=0.31.1", diff --git a/src/zedprofiler/IO/loading_classes.py b/src/zedprofiler/IO/loading_classes.py index 09b5c1b..1bd11e0 100644 --- a/src/zedprofiler/IO/loading_classes.py +++ b/src/zedprofiler/IO/loading_classes.py @@ -11,6 +11,8 @@ import numpy from beartype import beartype +from zedprofiler.contracts import ImageArrayModel + logging.basicConfig(level=logging.INFO) @@ -327,10 +329,14 @@ def _load_array_based_images( """ if image_set_array is not None: for key in config.raw_image_key_name: - self.image_set_dict[key] = image_set_array + # 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: - self.image_set_dict[key] = label_set_array + # 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: """ @@ -471,6 +477,8 @@ def __init__( 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] + # inherit the image set loader + self.image_set_loader = image_set_loader class TwoObjectLoader: @@ -539,3 +547,5 @@ def __init__( self.image1 = self.image_set_loader.get_image(channel1) self.image2 = self.image_set_loader.get_image(channel2) self.object_ids = image_set_loader.unique_compartment_objects[compartment] + # inherit the image set name for downstream use + self.image_set_name = image_set_loader.image_set_name diff --git a/src/zedprofiler/contracts.py b/src/zedprofiler/contracts.py index bca5604..d49c48b 100644 --- a/src/zedprofiler/contracts.py +++ b/src/zedprofiler/contracts.py @@ -14,13 +14,11 @@ from __future__ import annotations -import pathlib from typing import Any import numpy as np import pandas as pd -import pandera as pa -import tomli +import pandera.pandas as pa from beartype import beartype from pydantic import ( BaseModel, @@ -113,7 +111,14 @@ def validate_array_dtype_and_shape(_cls, arr: np.ndarray) -> np.ndarray: "dimensions of size 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." + ) + if not validate_image_array_type_contracts(arr): + raise ValueError( + f"Input array with dtype {arr.dtype} failed type contract validation." + ) return arr @@ -330,19 +335,21 @@ def validate_image_array_type_contracts( @beartype -def validate_image_with_pydantic(arr: np.ndarray) -> ImageArrayModel: +def validate_return_with_pydantic( + result: dict[str, object], +) -> ReturnSchemaModel: """ - Validate image array using Pydantic model. + Validate return schema using Pydantic model. Parameters ---------- - arr : np.ndarray - Input array to validate + result : dict[str, object] + Return result to validate Returns ------- - ImageArrayModel - Validated image array model + ReturnSchemaModel + Validated return schema model Raises ------ @@ -350,27 +357,28 @@ def validate_image_with_pydantic(arr: np.ndarray) -> ImageArrayModel: If validation fails """ try: - return ImageArrayModel(array=arr) + return ReturnSchemaModel(result=result) except Exception as e: - raise ContractError(f"Image array validation failed: {e}") + msg = ( + "Return schema validation failed. Please ensure that the data " + f"fit the expected schema: {e}" + ) + raise ContractError(msg) -@beartype -def validate_return_with_pydantic( - result: dict[str, object], -) -> ReturnSchemaModel: +def validate_image_with_pydantic(arr: np.ndarray) -> ImageArrayModel: """ - Validate return schema using Pydantic model. + Validate the input image array using Pydantic model. Parameters ---------- - result : dict[str, object] - Return result to validate + arr : np.ndarray + Input image array to validate Returns ------- - ReturnSchemaModel - Validated return schema model + ImageArrayModel + Validated image array model Raises ------ @@ -378,11 +386,11 @@ def validate_return_with_pydantic( If validation fails """ try: - return ReturnSchemaModel(result=result) + return ImageArrayModel(array=arr) except Exception as e: msg = ( - "Return schema validation failed. Please ensure that the data " - f"fit the expected schema: {e}" + "Image array validation failed. Please ensure that the input " + f"array meets the expected contracts: {e}" ) raise ContractError(msg) @@ -457,67 +465,48 @@ def validate_return_schema_contract( class ExpectedFeatureNameValues(BaseModel): """Pydantic model for expected values in feature naming validation.""" - config_file_path: pathlib.Path - compartments: list[str] = Field(default_factory=list) - channels: list[str] = Field(default_factory=list) - features: list[str] = Field(default_factory=list) - + compartments: list[str] | None = Field(default_factory=list) + channels: list[str] | None = Field(default_factory=list) + 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) - @field_validator("config_file_path", mode="before") - @classmethod - def validate_config_path(_cls, v: object) -> pathlib.Path: - """Ensure config_file_path is a valid Path object.""" - if not isinstance(v, pathlib.Path): - v = pathlib.Path(v) - return v - - def model_post_init(self, _context: object) -> None: - """Load expected values from a TOML configuration file.""" - config = tomli.loads(self.config_file_path.read_text()) - self.compartments = list(set(config["expected_values"]["compartments"])) - self.channels = list(set(config["expected_values"]["channels"])) - # add "NoChannel" as a valid channel for metadata columns - # This is automatically added in the ZedProfiler - # regardless of input channel we want this added - self.channels.append("NoChannel") - self.features = [ - "AreaSizeShape", - "Correlation", - "Granularity", - "Intensity", - "Neighbors", - "Texture", - "SAMMed3D", - "CHAMMI-75", - ] - - def to_dict(self) -> dict[str, list[str]]: - """Return expected values as a dictionary.""" - return { + def __init__(self, **data: object) -> None: + super().__init__(**data) + if self.compartments is not None: + self.compartments = list(set(self.compartments)) + else: + raise ValueError("Compartments list cannot be None.") + if self.channels is not None: + # Add "NoChannel" to channels list + self.channels = list(set(self.channels) | {"NoChannel"}) + else: + raise ValueError("Channels list cannot be None.") + if self.features is not None and len(self.features) > 0: + self.features = list(set(self.features)) + else: + self.features = [ + "Colocalization", + "Granularity", + "Texture", + "Intensity", + "Neighbors", + "VolumeSizeShape", + ] + self.expected_values_dict = { "compartments": self.compartments, "channels": self.channels, "features": self.features, } - def __init__(self, *args: object, **data: object) -> None: - """Support positional `config_file_path` for backward compatibility. - - Tests and existing code may instantiate ExpectedValues(path) using a - positional argument. Pydantic BaseModel requires keyword arguments, so - accept a single positional argument and forward it as - `config_file_path=` to the BaseModel initializer. - """ - if args and "config_file_path" not in data: - # take the first positional arg as config_file_path - data["config_file_path"] = args[0] - super().__init__(**data) - @beartype def validate_column_name_schema( column_name: str, - expected_values_config_path: pathlib.Path, + channels: list[str], + compartments: list[str], + features: list[str] | None = None, ) -> bool: """ Validate the column name schema for required fields and types @@ -526,8 +515,12 @@ def validate_column_name_schema( ---------- column_name : str The column name to validate - expected_values_config_path : pathlib.Path - Path to the configuration file containing expected values for validation + channels : list[str] + List of valid channels for feature naming + compartments : list[str] + List of valid compartments for feature naming + features : list[str] | None, optional + List of valid features for feature naming, by default None Returns ------- bool @@ -540,7 +533,10 @@ def validate_column_name_schema( non_metadata_underscore_separated_parts = NON_METADATA_UNDERSCORE_SEPARATED_PARTS metadata_underscore_separated_parts = METADATA_UNDERSCORE_SEPARATED_PARTS - expected_values = ExpectedFeatureNameValues(expected_values_config_path).to_dict() + expected_values = ExpectedFeatureNameValues( + channels=channels, compartments=compartments, features=None + ).expected_values_dict + # check if the column name is a string if not isinstance(column_name, str): raise ContractError(f"Column name must be a string, got {type(column_name)}") @@ -568,7 +564,6 @@ def validate_column_name_schema( f"underscores, got {len(parts)} parts in '{column_name}'" ) return True - feature_components = pd.DataFrame( [ { @@ -578,6 +573,7 @@ def validate_column_name_schema( } ] ) + feature_component_schema = pa.DataFrameSchema( { "compartment": pa.Column( @@ -601,6 +597,7 @@ def validate_column_name_schema( }, strict=True, ) + try: feature_component_schema.validate(feature_components) except (pa.errors.SchemaError, pa.errors.SchemaErrors) as e: diff --git a/src/zedprofiler/featurization/colocalization.py b/src/zedprofiler/featurization/colocalization.py index 2971f1e..8d421bf 100644 --- a/src/zedprofiler/featurization/colocalization.py +++ b/src/zedprofiler/featurization/colocalization.py @@ -7,17 +7,21 @@ from __future__ import annotations -from typing import Dict, Tuple +from collections.abc import Sequence +from typing import Dict, Protocol, Tuple import numpy +import pandas import scipy.ndimage import skimage +from zedprofiler.contracts import validate_column_name_schema from zedprofiler.image_utils.image_utils import ( crop_3D_image, new_crop_border, select_objects_from_label, ) +from zedprofiler.IO.feature_writing_utils import format_morphology_feature_name COSTES_R_FAR_THRESHOLD = 0.45 COSTES_R_MID_THRESHOLD = 0.35 @@ -28,6 +32,17 @@ UINT16_MAX = 65535 +class SupportsTwoObjectLoader(Protocol): + """Minimal loader interface required for paired-object colocalization.""" + + image_set_loader: object + compartment: str + image1: numpy.ndarray + image2: numpy.ndarray + label_image: numpy.ndarray + object_ids: Sequence[int] + + def _require_scipy() -> None: if scipy is None: raise ModuleNotFoundError( @@ -289,7 +304,7 @@ def prepare_two_images_for_colocalization( # noqa: PLR0913 return cropped_image_1, cropped_image_2 -def compute_colocalization( # noqa: PLR0912, PLR0915 +def calculate_colocalization( # noqa: PLR0912, PLR0915 cropped_image_1: numpy.ndarray, cropped_image_2: numpy.ndarray, thr: int = 15, @@ -501,3 +516,118 @@ def compute_colocalization( # noqa: PLR0912, PLR0915 results["RankWeightedColocalizationCoeff2"] = RWC2 return results + + +def compute_colocalization( # noqa: C901, PLR0912 + two_object_loader: SupportsTwoObjectLoader, + thr: int = 15, + 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. + + Parameters + ---------- + two_object_loader : SupportsTwoObjectLoader + The loader that provides access to the two channels and their + corresponding labels. + thr : int, optional + The threshold for the Manders' coefficients, by default 15 + fast_costes : str, optional + The mode for Costes' threshold calculation, by default "Accurate". + Options are "Accurate" or "Fast". + "Accurate" uses a linear algorithm, while "Fast" uses a bisection algorithm. + The "Fast" mode is faster but less accurate. + channel1 : str | None, optional + The name of the first channel, used for feature naming, by default None + channel2 : str | None, optional + The name of the second channel, used for feature naming, by default None + + Returns + ------- + dict[str, list[float]] + A dictionary containing lists of colocalization feature values for + each object pair. + """ + if channel1 is None or channel2 is None: + raise ValueError("channel1 and channel2 must be provided for feature naming.") + list_of_dfs = [] + for object_id in two_object_loader.object_ids: + cropped_image1, cropped_image2 = prepare_two_images_for_colocalization( + label_object1=two_object_loader.label_image, + label_object2=two_object_loader.label_image, + image_object1=two_object_loader.image1, + image_object2=two_object_loader.image2, + object_id1=object_id, + object_id2=object_id, + ) + colocalization_features = calculate_colocalization( + cropped_image_1=cropped_image1, + cropped_image_2=cropped_image2, + thr=15, + fast_costes="Accurate", + ) + + # Build a simple dict row (avoid pandas dependency) + row: dict[str, object] = {} + for meas_key, meas_val in colocalization_features.items(): + full_name = format_morphology_feature_name( + compartment=two_object_loader.compartment, + channel=f"{channel1}-{channel2}", + feature_type="Colocalization", + measurement=meas_key, + ) + # cast numeric values to float32 where appropriate + if full_name not in ( + "Metadata_Object_ObjectID", + "Metadata_Experiment_ImageSet", + ): + try: + row[full_name] = numpy.float32(meas_val) + except Exception: + row[full_name] = meas_val + else: + row[full_name] = meas_val + + # ensure object_id and image_set are present and first + row["Metadata_Object_ObjectID"] = object_id + row["Metadata_Experiment_ImageSet"] = ( + two_object_loader.image_set_loader.image_set_name + ) + list_of_dfs.append(row) + + # Convert list of row-dicts into a dict-of-lists with stable ordering + if not list_of_dfs: + return {} + + # Collect other metric keys preserving first-seen ordering + other_keys: list[str] = [] + for d in list_of_dfs: + for k in d: + if k in ("Metadata_Object_ObjectID", "Metadata_Experiment_ImageSet"): + continue + if k not in other_keys: + other_keys.append(k) + + all_keys = [ + "Metadata_Object_ObjectID", + "Metadata_Experiment_ImageSet", + *other_keys, + ] + result: dict[str, list[object]] = { + k: [r.get(k) for r in list_of_dfs] for k in all_keys + } + + for col in list(result.keys()): + try: + validate_column_name_schema( + column_name=col, + compartments=[two_object_loader.compartment], + channels=[f"{channel1}-{channel2}"], + ) + except ValueError as e: + raise ValueError(f"Column name {col} does not conform to schema: {e}") + + return pandas.DataFrame(result) diff --git a/src/zedprofiler/featurization/granularity.py b/src/zedprofiler/featurization/granularity.py index 9d79181..7726a3c 100644 --- a/src/zedprofiler/featurization/granularity.py +++ b/src/zedprofiler/featurization/granularity.py @@ -7,10 +7,12 @@ from typing import Dict, Optional import numpy +import pandas import scipy.ndimage import skimage.morphology -import tqdm +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 @@ -149,6 +151,12 @@ def compute_granularity( # noqa: C901, PLR0912, PLR0913, PLR0915 ``im.mask``. If None (default), all pixels are considered valid (all-True mask), matching the typical CellProfiler behavior for unmasked images. + channel : str or None + Optional channel name for feature naming. If None, channel is not + included in feature names. + compartment : str or None + Optional compartment name for feature naming. If None, compartment is + not included in feature names. Returns ------- @@ -294,7 +302,7 @@ def compute_granularity( # noqa: C901, PLR0912, PLR0913, PLR0915 # masked by im.mask: labels[~im.mask] = 0. # ------------------------------------------------------------------ object_measurements = { - "object_id": [], + "Metadata_Object_ObjectID": [], "feature": [], "value": [], } @@ -343,12 +351,7 @@ def compute_granularity( # noqa: C901, PLR0912, PLR0913, PLR0915 f"Spectrum length: {granular_spectrum_length}" ) - for scale in tqdm.tqdm( - range(1, granular_spectrum_length + 1), - desc="Granularity measurement", - position=1, - leave=False, - ): + for scale in range(1, granular_spectrum_length + 1): prevmean = currentmean # Masked erosion @@ -396,17 +399,58 @@ def compute_granularity( # noqa: C901, PLR0912, PLR0913, PLR0915 # Record measurements for each object for idx in range(len(label_range)): - object_measurements["object_id"].append(int(label_range[idx])) + object_measurements["Metadata_Object_ObjectID"].append( + int(label_range[idx]) + ) object_measurements["feature"].append(scale) object_measurements["value"].append(float(gss[idx])) if verbose: - n_total = len(object_measurements["object_id"]) + n_total = len(object_measurements["Metadata_Object_ObjectID"]) non_zero = sum(1 for v in object_measurements["value"] if v > 0) print(f"Total measurements: {n_total}") print(f"Non-zero measurements: {non_zero}") if non_zero > 0: vals = [v for v in object_measurements["value"] if v > 0] 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"] + ) + final_df.columns = final_df.columns.droplevel() + final_df = final_df.reset_index() + # prepend compartment and channel to column names + final_df.rename( + columns={ + col: format_morphology_feature_name( + compartment=object_loader.compartment, + channel=object_loader.channel, + feature_type="Granularity", + measurement=col, + ) + if col != "Metadata_Object_ObjectID" + else col + for col in final_df.columns + }, + inplace=True, + ) + final_df.insert( + 0, + "Metadata_Experiment_ImageSet", + 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( + column_name=col, + compartments=[object_loader.compartment], + channels=[f"{object_loader.channel}"], + ) + except ValueError as e: + raise ValueError(f"Column name {col} does not conform to schema: {e}") - return object_measurements + return final_df diff --git a/src/zedprofiler/featurization/intensity.py b/src/zedprofiler/featurization/intensity.py index 52e2b7a..a7feac9 100644 --- a/src/zedprofiler/featurization/intensity.py +++ b/src/zedprofiler/featurization/intensity.py @@ -6,9 +6,12 @@ """ import numpy +import pandas import scipy.ndimage import skimage.segmentation +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 @@ -34,7 +37,7 @@ def get_outline(mask: numpy.ndarray) -> numpy.ndarray: def compute_intensity( # noqa: PLR0915 object_loader: ObjectLoader, -) -> dict: +) -> pandas.DataFrame: """ Measure the intensity of objects in a 3D image. @@ -54,7 +57,7 @@ def compute_intensity( # noqa: PLR0915 labels = object_loader.object_ids output_dict = { - "object_id": [], + "Metadata_Object_ObjectID": [], "feature_name": [], "channel": [], "compartment": [], @@ -187,9 +190,49 @@ def compute_intensity( # noqa: PLR0915 coerced_value = numpy.float32(measurement_value) else: coerced_value = measurement_value - output_dict["object_id"].append(numpy.int32(label)) + output_dict["Metadata_Object_ObjectID"].append(numpy.int32(label)) output_dict["feature_name"].append(feature_name) output_dict["channel"].append(object_loader.channel) output_dict["compartment"].append(object_loader.compartment) output_dict["value"].append(coerced_value) - return output_dict + final_df = pandas.DataFrame(output_dict) + # prepend compartment and channel to column names + final_df = final_df.pivot( + index=["Metadata_Object_ObjectID"], + columns="feature_name", + values="value", + ).reset_index() + final_df.rename( + columns={ + col: format_morphology_feature_name( + compartment=object_loader.compartment, + channel=object_loader.channel, + feature_type="Intensity", + measurement=col, + ) + if col != "Metadata_Object_ObjectID" + else col + for col in final_df.columns + }, + inplace=True, + ) + + final_df.insert( + 0, + "Metadata_Experiment_ImageSet", + object_loader.image_set_loader.image_set_name, + ) + + # validate column names against schema + result = final_df.to_dict(orient="list") + for col in list(result.keys()): + try: + validate_column_name_schema( + column_name=col, + compartments=[object_loader.compartment], + channels=[f"{object_loader.channel}"], + ) + except ValueError as e: + raise ValueError(f"Column name {col} does not conform to schema: {e}") + + return final_df diff --git a/src/zedprofiler/featurization/neighbors.py b/src/zedprofiler/featurization/neighbors.py index d76ad3d..3c2e17a 100644 --- a/src/zedprofiler/featurization/neighbors.py +++ b/src/zedprofiler/featurization/neighbors.py @@ -7,6 +7,8 @@ import pandas 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] @@ -113,7 +115,7 @@ def compute_neighbors( image_global_max_coord_x = label_object.shape[2] neighbors_out_dict = { - "object_id": [], + "Metadata_Object_ObjectID": [], "NeighborsCountAdjacent": [], f"NeighborsCountByDistance-{distance_threshold}": [], } @@ -181,13 +183,47 @@ def compute_neighbors( n_neighbors_by_distance = ( len(numpy.unique(croppped_neighbor_image[croppped_neighbor_image > 0])) - 1 ) - neighbors_out_dict["object_id"].append(label) + 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 ) + final_df = pandas.DataFrame(neighbors_out_dict) + # rename + final_df.rename( + columns={ + col: format_morphology_feature_name( + compartment=object_loader.compartment, + channel=object_loader.channel, + feature_type="Neighbors", + measurement=col, + ) + if col != "Metadata_Object_ObjectID" + else col + for col in final_df.columns + }, + inplace=True, + ) + if not final_df.empty: + final_df.insert( + 0, + "Metadata_Experiment_ImageSet", + object_loader.image_set_loader.image_set_name, + ) + + # validate column names against schema + result = final_df.to_dict(orient="list") + for col in list(result.keys()): + try: + validate_column_name_schema( + column_name=col, + compartments=[object_loader.compartment], + channels=[f"{object_loader.channel}"], + ) + except ValueError as e: + raise ValueError(f"Column name {col} does not conform to schema: {e}") - return neighbors_out_dict + return final_df def get_coordinates( @@ -210,12 +246,12 @@ def get_coordinates( """ if object_ids is None: object_ids = [] - coords = {"object_id": [], "x": [], "y": [], "z": []} + coords = {"Metadata_Object_ObjectID": [], "x": [], "y": [], "z": []} for obj_id in object_ids: z, y, x = numpy.where(nuclei_mask == obj_id) centroid = (numpy.mean(x), numpy.mean(y), numpy.mean(z)) - coords["object_id"].append(obj_id) + coords["Metadata_Object_ObjectID"].append(obj_id) coords["x"].append(centroid[0]) coords["y"].append(centroid[1]) coords["z"].append(centroid[2]) @@ -340,14 +376,14 @@ def classify_cells_into_shells( """ # Handle both DataFrame and dict input if isinstance(coords, pandas.DataFrame): - object_ids = coords["object_id"].to_numpy() + object_ids = coords["Metadata_Object_ObjectID"].to_numpy() coords_array = coords[["x", "y", "z"]].to_numpy() else: - object_ids = numpy.array(coords["object_id"]) + 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 = { - "object_id": [], + "Metadata_Object_ObjectID": [], "ShellAssignments": [], "DistancesFromCenter": [], "DistancesFromExterior": [], @@ -396,7 +432,7 @@ def classify_cells_into_shells( distance_from_exterior = max_distance - distances results = { - "object_id": object_ids, + "Metadata_Object_ObjectID": object_ids, "ShellAssignments": shell_assignments, "DistancesFromCenter": distances, "DistancesFromExterior": distance_from_exterior, diff --git a/src/zedprofiler/featurization/texture.py b/src/zedprofiler/featurization/texture.py index e3d67a2..32a9b4d 100644 --- a/src/zedprofiler/featurization/texture.py +++ b/src/zedprofiler/featurization/texture.py @@ -9,9 +9,12 @@ import mahotas import numpy +import pandas import skimage 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 @@ -56,7 +59,7 @@ def scale_image(image: numpy.ndarray, num_gray_levels: int = 256) -> numpy.ndarr ) -def compute_texture( +def compute_texture( # noqa: C901 object_loader: ObjectLoader, distance: int = 1, grayscale: int = 256, @@ -125,7 +128,7 @@ def compute_texture( n_directions = 13 output_texture_dict = { - "object_id": [], + "Metadata_Object_ObjectID": [], "texture_name": [], "texture_value": [], } @@ -183,9 +186,50 @@ def compute_texture( direction_str = f"{direction:02d}" for feature_name, feature in zip(feature_names, direction_features): for object_id, feature_value in zip(labels, feature): - output_texture_dict["object_id"].append(object_id) + output_texture_dict["Metadata_Object_ObjectID"].append(object_id) output_texture_dict["texture_name"].append( f"{feature_name}-{distance}-{direction_str}-{grayscale}" ) output_texture_dict["texture_value"].append(feature_value) - return output_texture_dict + final_df = pandas.DataFrame(output_texture_dict) + + final_df = final_df.pivot( + index="Metadata_Object_ObjectID", + columns="texture_name", + values="texture_value", + ) + final_df.reset_index(inplace=True) + final_df.rename( + columns={ + col: format_morphology_feature_name( + compartment=object_loader.compartment, + channel=object_loader.channel, + feature_type="Texture", + measurement=col, + ) + if col != "Metadata_Object_ObjectID" + else col + for col in final_df.columns + }, + inplace=True, + ) + final_df.insert( + 0, + "Metadata_Experiment_ImageSet", + object_loader.image_set_loader.image_set_name, + ) + final_df.columns.name = None + + # validate column names against schema + result = final_df.to_dict(orient="list") + for col in list(result.keys()): + try: + validate_column_name_schema( + column_name=col, + compartments=[object_loader.compartment], + channels=[f"{object_loader.channel}"], + ) + except ValueError as e: + raise ValueError(f"Column name {col} does not conform to schema: {e}") + + return final_df diff --git a/src/zedprofiler/featurization/volumesizeshape.py b/src/zedprofiler/featurization/volumesizeshape.py index 6c1c2ce..fc53c40 100644 --- a/src/zedprofiler/featurization/volumesizeshape.py +++ b/src/zedprofiler/featurization/volumesizeshape.py @@ -7,8 +7,11 @@ from typing import Protocol import numpy as np +import pandas +from zedprofiler.contracts import validate_column_name_schema from zedprofiler.exceptions import ZedProfilerError +from zedprofiler.IO.feature_writing_utils import format_morphology_feature_name class SupportsImageSetLoader(Protocol): @@ -31,7 +34,7 @@ class SupportsObjectLoader(Protocol): def _empty_feature_result() -> dict[str, list[float]]: """Return deterministic empty output schema for area/size/shape features.""" return { - "object_id": [], + "Metadata_Object_ObjectID": [], "Volume": [], "CenterX": [], "CenterY": [], @@ -50,7 +53,7 @@ def _empty_feature_result() -> dict[str, list[float]]: } -def compute( +def compute_volume_size_shape( image_set_loader: SupportsImageSetLoader | None = None, object_loader: SupportsObjectLoader | None = None, ) -> dict[str, list[float]]: @@ -145,7 +148,7 @@ def measure_3D_volume_size_shape( properties=desired_properties, ) - features_to_record["object_id"].append(label) + features_to_record["Metadata_Object_ObjectID"].append(label) features_to_record["Volume"].append(props["area"].item()) features_to_record["CenterX"].append(props["centroid-2"].item()) features_to_record["CenterY"].append(props["centroid-1"].item()) @@ -174,4 +177,40 @@ def measure_3D_volume_size_shape( except (RuntimeError, ValueError): features_to_record["SurfaceArea"].append(np.nan) - return features_to_record + final_df = pandas.DataFrame(features_to_record) + + # prepend compartment and channel to column names + final_df.rename( + columns={ + col: format_morphology_feature_name( + compartment=object_loader.compartment, + channel=object_loader.channel, + feature_type="VolumeSizeShape", + measurement=col, + ) + if col != "Metadata_Object_ObjectID" + else col + for col in final_df.columns + }, + inplace=True, + ) + + final_df.insert( + 1, + "Metadata_Experiment_ImageSet", + object_loader.image_set_loader.image_set_name, + ) + + # validate column names against schema + result = final_df.to_dict(orient="list") + for col in list(result.keys()): + try: + validate_column_name_schema( + column_name=col, + compartments=[object_loader.compartment], + channels=[f"{object_loader.channel}"], + ) + except ValueError as e: + raise ValueError(f"Column name {col} does not conform to schema: {e}") + + return final_df diff --git a/tests/IO/test_loading_classes.py b/tests/IO/test_loading_classes.py index 2de976e..17ad002 100644 --- a/tests/IO/test_loading_classes.py +++ b/tests/IO/test_loading_classes.py @@ -267,8 +267,14 @@ def test_init_from_arrays_populates_image_set_dict( self, ) -> None: """Array-backed initialization should populate image_set_dict directly.""" - image_array = np.ones((2, 2), dtype=np.int32) - label_array = np.array([[ZERO_LABEL, ONE_LABEL]], dtype=np.int32) + image_array = np.ones((2, 2, 2), dtype=np.float32) + label_array = np.array( + [ + [[ZERO_LABEL, ONE_LABEL], [TWO_LABEL, TWO_LABEL]], + [[ZERO_LABEL, ONE_LABEL], [TWO_LABEL, TWO_LABEL]], + ], + dtype=np.int32, + ) loader = ImageSetLoader( image_set_path=None, @@ -276,17 +282,18 @@ def test_init_from_arrays_populates_image_set_dict( image_set_array=image_array, label_set_array=label_array, anisotropy_spacing=(2.0, 1.0, 1.0), - channel_mapping={}, + channel_mapping={ + "DNA": "dna_raw", + "Nuclei_label": "nuc_label", + }, config=ImageSetConfig( - label_key_name=["Nuclei_label"], + label_key_name=["Nuclei-label"], raw_image_key_name=["DNA"], ), ) assert np.array_equal(loader.get_image("DNA"), image_array) - assert np.array_equal(loader.get_image("Nuclei_label"), label_array) - assert loader.image_names == [] - assert loader.compartments == ["DNA", "Nuclei_label"] + assert np.array_equal(loader.get_image("Nuclei-label"), label_array) def test_init_with_none_image_path_raises_value_error( self, @@ -335,22 +342,43 @@ def test_object_loader_drops_background_id(self) -> None: def test_two_object_loader_loads_images_and_ids(self) -> None: """TwoObjectLoader should load the expected arrays and preserve object IDs.""" - image_set_loader = ImageSetLoader.__new__(ImageSetLoader) - image_set_loader.image_set_dict = { - "Nuclei_label": np.array([[ZERO_LABEL, ONE_LABEL]], dtype=np.int32), - "DNA": np.array([[10, 11]], dtype=np.int32), - "RNA": np.array([[20, 21]], dtype=np.int32), - } - image_set_loader.unique_compartment_objects = {"Nuclei_label": [ONE_LABEL]} + # label imabe should be 3D + label_image = np.array( + [ + [[ZERO_LABEL, ONE_LABEL], [TWO_LABEL, TWO_LABEL]], + [[ZERO_LABEL, ONE_LABEL], [TWO_LABEL, TWO_LABEL]], + ], + dtype=np.int32, + ) + # establish this 3D image where each dimension is ZYX + # 2x2x2 array of ones to match the label image shape for simplicity + image = np.ones((2, 2, 2), dtype=np.float32) + image_set_loader = ImageSetLoader( + image_set_path=None, + label_set_path=None, + image_set_array=image, + label_set_array=label_image, + anisotropy_spacing=(2.0, 1.0, 1.0), + channel_mapping={ + "DNA": "dna_raw", + "AGP": "agp_raw", + "Nuclei-label": "nuc_label", + }, + config=ImageSetConfig( + label_key_name=["Nuclei-label"], + raw_image_key_name=["DNA", "AGP"], + ), + ) - two = TwoObjectLoader( + obj = TwoObjectLoader( image_set_loader=image_set_loader, - compartment="Nuclei_label", channel1="DNA", - channel2="RNA", + channel2="AGP", + compartment="Nuclei-label", ) - assert two.object_ids == [ONE_LABEL] - assert np.array_equal(two.label_image, np.array([[ZERO_LABEL, ONE_LABEL]])) - assert np.array_equal(two.image1, np.array([[10, 11]])) - assert np.array_equal(two.image2, np.array([[20, 21]])) + assert obj.compartment == "Nuclei-label" + assert np.array_equal(obj.image1, image) + assert np.array_equal(obj.image2, image) + assert np.array_equal(obj.label_image, label_image) + assert obj.object_ids == [ONE_LABEL, TWO_LABEL] diff --git a/tests/featurization/test_colocalization.py b/tests/featurization/test_colocalization.py deleted file mode 100644 index d18cfb1..0000000 --- a/tests/featurization/test_colocalization.py +++ /dev/null @@ -1,128 +0,0 @@ -import numpy as np -import pytest -from _pytest.monkeypatch import MonkeyPatch - -from zedprofiler.featurization import colocalization as coloc - - -def test_linear_costes_threshold_calculation_returns_finite_thresholds() -> None: - x = np.linspace(0.01, 1.0, 200) - y = 0.85 * x + 0.05 * np.sin(np.arange(x.size)) - t1, t2 = coloc.linear_costes_threshold_calculation( - x, y, scale_max=255, fast_costes="Fast" - ) - assert np.isfinite(t1) - assert np.isfinite(t2) - - -def test_bisection_costes_threshold_calculation_returns_finite_thresholds() -> None: - x = np.linspace(0.01, 1.0, 200) - y = 0.9 * x + 0.03 * np.cos(np.arange(x.size)) - t1, t2 = coloc.bisection_costes_threshold_calculation(x, y, scale_max=255) - assert np.isfinite(t1) - assert np.isfinite(t2) - - -def test_prepare_two_images_for_colocalization_crops_expected_regions( - monkeypatch: MonkeyPatch, -) -> None: - label1 = np.zeros((4, 4, 4), dtype=int) - label2 = np.zeros((4, 4, 4), dtype=int) - label1[1:3, 1:3, 1:3] = 1 - label2[0:2, 0:2, 0:2] = 2 - - img1 = np.arange(64).reshape(4, 4, 4) - img2 = np.arange(100, 164).reshape(4, 4, 4) - - monkeypatch.setattr(coloc, "select_objects_from_label", lambda arr, _: arr) - monkeypatch.setattr(coloc, "new_crop_border", lambda b1, b2, _img: (b1, b2)) - monkeypatch.setattr( - coloc, - "crop_3D_image", - lambda img, bbox: img[bbox[0] : bbox[3], bbox[1] : bbox[4], bbox[2] : bbox[5]], - ) - - out1, out2 = coloc.prepare_two_images_for_colocalization( - label1, label2, img1, img2, object_id1=1, object_id2=2 - ) - - assert out1.shape == (2, 2, 2) - assert out2.shape == (2, 2, 2) - np.testing.assert_array_equal(out1, img1[1:3, 1:3, 1:3]) - np.testing.assert_array_equal(out2, img2[0:2, 0:2, 0:2]) - - -def test_compute_colocalization_identical_images_are_highly_colocalized() -> None: - arr = np.array( - [ - [[1, 2], [3, 4]], - [[5, 6], [7, 8]], - ], - dtype=float, - ) - res = coloc.compute_colocalization(arr, arr, thr=0, fast_costes="Fast") - - expected_keys = { - "Correlation", - "MandersCoeffM1", - "MandersCoeffM2", - "OverlapCoeff", - "MandersCoeffCostesM1", - "MandersCoeffCostesM2", - "RankWeightedColocalizationCoeff1", - "RankWeightedColocalizationCoeff2", - } - assert expected_keys.issubset(res.keys()) - assert res["Correlation"] == pytest.approx(1.0, rel=1e-6) - assert res["MandersCoeffM1"] == pytest.approx(1.0, rel=1e-6) - assert res["MandersCoeffM2"] == pytest.approx(1.0, rel=1e-6) - assert res["OverlapCoeff"] == pytest.approx(1.0, rel=1e-6) - - -def test_compute_colocalization_empty_combined_threshold_path() -> None: - a = np.ones((2, 2, 2), dtype=float) - b = np.ones((2, 2, 2), dtype=float) * 2.0 - - res = coloc.compute_colocalization(a, b, thr=200, fast_costes="Fast") - - assert res["MandersCoeffM1"] == 0.0 - assert res["MandersCoeffM2"] == 0.0 - assert res["RankWeightedColocalizationCoeff1"] == 0.0 - assert res["RankWeightedColocalizationCoeff2"] == 0.0 - assert np.isnan(res["OverlapCoeff"]) - - -def test_compute_colocalization_costes_dispatch(monkeypatch: MonkeyPatch) -> None: - calls = {"bisection": 0, "linear": 0} - - def fake_bisection( - _i1: np.ndarray, _i2: np.ndarray, _scale: int - ) -> tuple[float, float]: - calls["bisection"] += 1 - return 0.1, 0.1 - - def fake_linear( - _i1: np.ndarray, - _i2: np.ndarray, - _scale: int, - _mode: str, - ) -> tuple[float, float]: - calls["linear"] += 1 - return 0.1, 0.1 - - monkeypatch.setattr(coloc, "bisection_costes_threshold_calculation", fake_bisection) - monkeypatch.setattr(coloc, "linear_costes_threshold_calculation", fake_linear) - - img1 = np.arange(1, 9, dtype=float).reshape(2, 2, 2) - img2 = img1 + 1.0 - - coloc.compute_colocalization(img1, img2, fast_costes="Accurate") - coloc.compute_colocalization(img1, img2, fast_costes="Fast") - - assert calls["bisection"] == 1 - assert calls["linear"] == 1 - - -def test_compute_colocalization_empty_input_raises() -> None: - with pytest.raises(UnboundLocalError): - coloc.compute_colocalization(np.array([]), np.array([])) diff --git a/tests/featurization/test_granularity.py b/tests/featurization/test_granularity.py deleted file mode 100644 index a1d944e..0000000 --- a/tests/featurization/test_granularity.py +++ /dev/null @@ -1,151 +0,0 @@ -import re -from types import SimpleNamespace - -import numpy as np -import pytest -from _pytest.capture import CaptureFixture - -from zedprofiler.featurization.granularity import ( - _fix_scipy_ndimage_result, - _subsample_3d, - _upsample_3d, - compute_granularity, -) - -EXPECTED_THREE_SCALES = 3 -EXPECTED_TWO_SCALES = 2 - - -def _make_loader(image: np.ndarray, labels: np.ndarray) -> SimpleNamespace: - return SimpleNamespace(image=image, label_image=labels) - - -def test_fix_scipy_ndimage_result_handles_scalar_and_sequence() -> None: - scalar_result = _fix_scipy_ndimage_result(3.14) - sequence_result = _fix_scipy_ndimage_result([1.0, 2.0]) - - np.testing.assert_array_equal(scalar_result, np.array([3.14])) - np.testing.assert_array_equal(sequence_result, np.array([1.0, 2.0])) - - -def test_subsample_3d_returns_copy_when_factor_is_one() -> None: - data = np.arange(4 * 4 * 4, dtype=float).reshape(4, 4, 4) - out = _subsample_3d(data, np.array(data.shape, dtype=float), subsample_factor=1.0) - - np.testing.assert_array_equal(out, data) - assert out is not data - - -def test_subsample_3d_reduces_shape_for_fractional_factor() -> None: - data = np.arange(4 * 4 * 4, dtype=float).reshape(4, 4, 4) - new_shape = np.array(data.shape, dtype=float) * 0.5 - - out = _subsample_3d(data, new_shape, subsample_factor=0.5) - - assert out.shape == (2, 2, 2) - - -def test_upsample_3d_restores_requested_shape() -> None: - data = np.arange(2 * 2 * 2, dtype=float).reshape(2, 2, 2) - - out = _upsample_3d( - data=data, - subsampled_shape=np.array([2.0, 2.0, 2.0]), - original_shape=(4, 4, 4), - ) - - assert out.shape == (4, 4, 4) - - -@pytest.mark.parametrize( - ("kwargs", "error_text"), - [ - ({"subsample_size": 0.0}, "subsample_size must be in (0, 1]"), - ({"image_sample_size": 0.0}, "image_sample_size must be in (0, 1]"), - ({"radius": 0}, "radius must be positive"), - ({"granular_spectrum_length": 0}, "granular_spectrum_length must be positive"), - ], -) -def test_compute_granularity_validates_inputs( - kwargs: dict[str, float | int], - error_text: str, -) -> None: - image = np.ones((4, 4, 4), dtype=float) - labels = np.ones((4, 4, 4), dtype=int) - loader = _make_loader(image, labels) - - with pytest.raises(ValueError, match=re.escape(error_text)): - compute_granularity(loader, **kwargs) - - -def test_compute_granularity_returns_empty_measurements_when_no_objects() -> None: - image = np.ones((6, 6, 6), dtype=float) - labels = np.zeros((6, 6, 6), dtype=int) - loader = _make_loader(image, labels) - - result = compute_granularity( - loader, - radius=1, - granular_spectrum_length=2, - subsample_size=1.0, - image_sample_size=1.0, - verbose=False, - ) - - assert result == {"object_id": [], "feature": [], "value": []} - - -def test_compute_granularity_generates_measurements_for_objects() -> None: - image = np.zeros((7, 7, 7), dtype=float) - image[2:5, 2:5, 2:5] = 20.0 - labels = np.zeros((7, 7, 7), dtype=int) - labels[2:5, 2:5, 2:5] = 1 - loader = _make_loader(image, labels) - - result = compute_granularity( - loader, - radius=1, - granular_spectrum_length=3, - subsample_size=1.0, - image_sample_size=1.0, - verbose=False, - ) - - assert result["object_id"] == [1, 1, 1] - assert result["feature"] == [1, 2, 3] - # Expected per-scale granularity percentages for the single object - assert result["value"] == [100.0, 0.0, 0.0] - - -def test_compute_granularity_with_subsampling_mask_and_verbose( - capsys: CaptureFixture[str], -) -> None: - image = np.zeros((8, 8, 8), dtype=float) - image[2:6, 2:6, 2:6] = 30.0 - labels = np.zeros((8, 8, 8), dtype=int) - labels[2:6, 2:6, 2:6] = 1 - mask = np.ones((8, 8, 8), dtype=bool) - mask[0, :, :] = False - loader = _make_loader(image, labels) - - result = compute_granularity( - loader, - radius=1, - granular_spectrum_length=2, - subsample_size=0.75, - image_sample_size=0.5, - mask_threshold=0.5, - verbose=True, - image_mask=mask, - ) - - captured = capsys.readouterr() - - assert "Subsampled image" in captured.out - assert "Background removed via tophat filter." in captured.out - assert result["object_id"] == [1, 1] - assert result["feature"] == [1, 2] - # Expected per-scale granularity percentages (floating values) - assert result["value"] == pytest.approx( - [57.91469603714501, 42.085303962855], rel=1e-12 - ) diff --git a/tests/featurization/test_intensity.py b/tests/featurization/test_intensity.py deleted file mode 100644 index 0391e02..0000000 --- a/tests/featurization/test_intensity.py +++ /dev/null @@ -1,143 +0,0 @@ -from types import SimpleNamespace - -import numpy as np - -from zedprofiler.featurization.intensity import compute_intensity, get_outline - -EXPECTED_MEASUREMENT_COUNT = 42 -EXPECTED_OBJECT_ONE_PEAK_COORD = 0.0 -EXPECTED_OBJECT_TWO_PEAK_COORD = 3.0 - - -def test_get_outline_marks_boundaries_per_slice() -> None: - mask = np.zeros((2, 4, 4), dtype=bool) - mask[0, 1:3, 1:3] = True - mask[1, 0:2, 0:2] = True - - outline = get_outline(mask) - - assert outline.shape == mask.shape - assert outline.dtype == bool - assert outline[0].any() - assert outline[1].any() - - -def test_compute_intensity_returns_measurements_for_objects() -> None: - image = np.zeros((3, 3, 3), dtype=float) - image[0, 0, 0] = 1.0 - image[0, 0, 1] = 2.0 - image[0, 1, 0] = 3.0 - image[0, 1, 1] = 4.0 - image[1, 1, 1] = 5.0 - image[1, 1, 2] = 6.0 - - labels = np.zeros((3, 3, 3), dtype=int) - labels[0, 0:2, 0:2] = 1 - labels[1, 1, 1:3] = 2 - - loader = SimpleNamespace( - image=image, - label_image=labels, - object_ids=[1, 2], - channel="channel_a", - compartment="nuclei", - ) - - result = compute_intensity(loader) - - expected_features = { - "IntegratedIntensity", - "MeanIntensity", - "StdIntensity", - "MinIntensity", - "MaxIntensity", - "LowerQuartileIntensity", - "UpperQuartileIntensity", - "MedianIntensity", - "MassDisplacement", - "MeanAbsoluteDeviationIntensity", - "IntegratedIntensityEdge", - "MeanIntensityEdge", - "StdIntensityEdge", - "MinIntensityEdge", - "MaxIntensityEdge", - "MaxZ", - "MaxY", - "MaxX", - "CMI.X", - "CMI.Y", - "CMI.Z", - } - - assert set(result) == { - "object_id", - "feature_name", - "channel", - "compartment", - "value", - } - assert len(result["object_id"]) == EXPECTED_MEASUREMENT_COUNT - assert set(result["feature_name"]) == expected_features - assert result["channel"] == ["channel_a"] * EXPECTED_MEASUREMENT_COUNT - assert result["compartment"] == ["nuclei"] * EXPECTED_MEASUREMENT_COUNT - assert len(result["value"]) == EXPECTED_MEASUREMENT_COUNT - - -def test_compute_intensity_skips_empty_object_without_signal() -> None: - image = np.zeros((2, 2, 2), dtype=float) - labels = np.zeros((2, 2, 2), dtype=int) - labels[0, 0, 0] = 1 - - loader = SimpleNamespace( - image=image, - label_image=labels, - object_ids=[1], - channel="channel_b", - compartment="cell", - ) - - result = compute_intensity(loader) - - assert result == { - "object_id": [], - "feature_name": [], - "channel": [], - "compartment": [], - "value": [], - } - - -def test_compute_intensity_peak_location_is_within_object() -> None: - image = np.zeros((4, 4, 4), dtype=float) - image[0, 0, 0] = 10.0 - image[3, 3, 3] = 100.0 - - labels = np.zeros((4, 4, 4), dtype=int) - labels[0, 0, 0] = 1 - labels[3, 3, 3] = 2 - - loader = SimpleNamespace( - image=image, - label_image=labels, - object_ids=[1, 2], - channel="channel_c", - compartment="nuclei", - ) - - result = compute_intensity(loader) - - def _value_for(object_id: int, feature_name: str) -> float: - for idx, current_object_id in enumerate(result["object_id"]): - if ( - int(current_object_id) == object_id - and result["feature_name"][idx] == feature_name - ): - return float(result["value"][idx]) - raise AssertionError(f"Missing {feature_name} for object {object_id}") - - assert _value_for(1, "MaxZ") == EXPECTED_OBJECT_ONE_PEAK_COORD - assert _value_for(1, "MaxY") == EXPECTED_OBJECT_ONE_PEAK_COORD - assert _value_for(1, "MaxX") == EXPECTED_OBJECT_ONE_PEAK_COORD - assert _value_for(2, "MaxZ") == EXPECTED_OBJECT_TWO_PEAK_COORD - assert _value_for(2, "MaxY") == EXPECTED_OBJECT_TWO_PEAK_COORD - assert _value_for(2, "MaxX") == EXPECTED_OBJECT_TWO_PEAK_COORD diff --git a/tests/featurization/test_neighbors.py b/tests/featurization/test_neighbors.py deleted file mode 100644 index 1dd1c66..0000000 --- a/tests/featurization/test_neighbors.py +++ /dev/null @@ -1,257 +0,0 @@ -import sys -import types - -import matplotlib -import numpy as np -import pandas as pd -import pytest - -from zedprofiler.featurization import neighbors as neighbors_module - -matplotlib.use("Agg", force=True) - - -EXPECTED_SHELLS_USED = 2 -EXPECTED_OBJECT_COUNT = 6 -EXPECTED_SECOND_OBJECT_ID = 2 - -if "image_analysis_3D" not in sys.modules: - image_analysis_3D = types.ModuleType("image_analysis_3D") - image_analysis_3D.__path__ = [] # type: ignore[attr-defined] - sys.modules["image_analysis_3D"] = image_analysis_3D - -if "image_analysis_3D.featurization_utils" not in sys.modules: - featurization_utils = types.ModuleType("image_analysis_3D.featurization_utils") - featurization_utils.__path__ = [] # type: ignore[attr-defined] - sys.modules["image_analysis_3D.featurization_utils"] = featurization_utils - -if "image_analysis_3D.featurization_utils.loading_classes" not in sys.modules: - loading_classes = types.ModuleType( - "image_analysis_3D.featurization_utils.loading_classes" - ) - - class ObjectLoader: - pass - - loading_classes.ObjectLoader = ObjectLoader - sys.modules["image_analysis_3D.featurization_utils.loading_classes"] = ( - loading_classes - ) - - -def test_neighbors_expand_box_clamps_to_global_bounds() -> None: - result = neighbors_module.neighbors_expand_box( - min_coor=0, - max_coord=10, - current_min=2, - current_max=8, - expand_by=3, - ) - assert result == (0, 10) - - -def test_neighbors_expand_box_expands_without_clamping() -> None: - result = neighbors_module.neighbors_expand_box( - min_coor=0, - max_coord=10, - current_min=2, - current_max=8, - expand_by=1, - ) - assert result == (1, 9) - - -def test_crop_3d_image_returns_expected_subvolume() -> None: - image = np.arange(3 * 4 * 5).reshape(3, 4, 5) - cropped = neighbors_module.crop_3D_image(image=image, bbox=(1, 1, 1, 3, 4, 5)) - assert cropped.shape == (2, 3, 4) - np.testing.assert_array_equal(cropped, image[1:3, 1:4, 1:5]) - - -def test_compute_neighbors_counts_adjacent_and_distance_neighbors() -> None: - label_image = np.zeros((1, 1, 4), dtype=int) - label_image[0, 0, 0] = 1 - label_image[0, 0, 1] = 2 - label_image[0, 0, 3] = 3 - - object_loader = types.SimpleNamespace( - label_image=label_image, - object_ids=[1, 2, 3], - ) - - result = neighbors_module.compute_neighbors( - object_loader=object_loader, - distance_threshold=1, - anisotropy_factor=1, - ) - - assert result["object_id"] == [1, 2, 3] - assert result["NeighborsCountAdjacent"] == [0, 0, 0] - assert result["NeighborsCountByDistance-1"] == [1, 1, 0] - - -def test_get_coordinates_returns_centroids_for_selected_objects() -> None: - nuclei_mask = np.zeros((2, 2, 2), dtype=int) - nuclei_mask[0, 0, 0] = 1 - nuclei_mask[1, 1, 1] = 2 - - coords = neighbors_module.get_coordinates(nuclei_mask, object_ids=[1, 2]) - - assert list(coords.columns) == ["object_id", "x", "y", "z"] - assert coords.shape == (2, 4) - assert coords.loc[coords["object_id"] == 1, ["x", "y", "z"]].iloc[0].tolist() == [ - 0.0, - 0.0, - 0.0, - ] - assert coords.loc[ - coords["object_id"] == EXPECTED_SECOND_OBJECT_ID, ["x", "y", "z"] - ].iloc[0].tolist() == [ - 1.0, - 1.0, - 1.0, - ] - - -def test_calculate_centroid_uses_column_mean() -> None: - coords = np.array([[0.0, 0.0, 0.0], [2.0, 4.0, 6.0]]) - centroid = neighbors_module.calculate_centroid(coords) - np.testing.assert_allclose(centroid, np.array([1.0, 2.0, 3.0])) - - -def test_euclidean_distance_from_centroid_matches_expected_values() -> None: - coords = np.array([[1.0, 1.0, 1.0], [4.0, 5.0, 6.0]]) - centroid = np.array([1.0, 1.0, 1.0]) - - distances = neighbors_module.euclidean_distance_from_centroid(coords, centroid) - - np.testing.assert_allclose(distances, np.array([0.0, np.sqrt(50.0)])) - - -def test_mahalanobis_distance_falls_back_to_euclidean_for_small_samples() -> None: - coords = np.array([[0.0, 0.0, 0.0], [3.0, 4.0, 0.0]]) - centroid = np.array([0.0, 0.0, 0.0]) - - mahalanobis = neighbors_module.mahalanobis_distance_from_centroid(coords, centroid) - euclidean = neighbors_module.euclidean_distance_from_centroid(coords, centroid) - - np.testing.assert_allclose(mahalanobis, euclidean) - - -def test_mahalanobis_distance_uses_pseudo_inverse_for_singular_covariance() -> None: - coords = np.zeros((20, 3)) - centroid = np.zeros(3) - - distances = neighbors_module.mahalanobis_distance_from_centroid(coords, centroid) - - np.testing.assert_allclose(distances, np.zeros(20)) - - -def test_classify_cells_into_shells_handles_empty_input() -> None: - results, centroid = neighbors_module.classify_cells_into_shells( - coords={"object_id": [], "x": [], "y": [], "z": []} - ) - - assert centroid is None - assert results == { - "object_id": [], - "ShellAssignments": [], - "DistancesFromCenter": [], - "DistancesFromExterior": [], - "NormalizedDistancesFromCenter": [], - "MaxShellsUsed": [], - } - - -def test_classify_cells_into_shells_adjusts_shell_count_and_returns_results() -> None: - coords = pd.DataFrame( - { - "object_id": [1, 2, 3, 4, 5, 6], - "x": [0.0, 1.0, 2.0, 3.0, 4.0, 5.0], - "y": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - "z": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - } - ) - - results, centroid = neighbors_module.classify_cells_into_shells( - coords=coords, - n_shells=5, - method="euclidean", - min_cells_per_shell=3, - ) - - assert centroid.shape == (3,) - assert results["ShellsUsed"] == EXPECTED_SHELLS_USED - assert len(results["object_id"]) == EXPECTED_OBJECT_COUNT - assert len(results["ShellAssignments"]) == EXPECTED_OBJECT_COUNT - assert len(results["DistancesFromCenter"]) == EXPECTED_OBJECT_COUNT - assert len(results["DistancesFromExterior"]) == EXPECTED_OBJECT_COUNT - assert len(results["NormalizedDistancesFromCenter"]) == EXPECTED_OBJECT_COUNT - - -def test_create_results_dataframe_builds_dataframe_from_results_dict() -> None: - results = { - "object_id": np.array([1, 2]), - "ShellAssignments": np.array([0, 1]), - "DistancesFromCenter": np.array([0.5, 1.5]), - "DistancesFromExterior": np.array([1.0, 0.0]), - "NormalizedDistancesFromCenter": np.array([0.25, 1.0]), - "ShellsUsed": 2, - } - - df = neighbors_module.create_results_dataframe(results) - - assert list(df.columns) == [ - "object_id", - "ShellAssignments", - "DistancesFromCenter", - "DistancesFromExterior", - "NormalizedDistancesFromCenter", - "ShellsUsed", - ] - assert df.shape == (2, 6) - - -def test_create_results_dataframe_rejects_non_dict_input() -> None: - with pytest.raises(ValueError, match="Input must be a results dictionary"): - neighbors_module.create_results_dataframe([1, 2, 3]) - - -def test_visualize_organoid_shells_returns_figure() -> None: - coords = pd.DataFrame( - { - "object_id": [1, 2, 3], - "x": [0.0, 1.0, 2.0], - "y": [0.0, 1.0, 2.0], - "z": [0.0, 1.0, 2.0], - } - ) - classification_results = { - "ShellAssignments": np.array([0, 1, 1]), - "ShellsUsed": 2, - } - - fig = neighbors_module.visualize_organoid_shells( - coords=coords, - classification_results=classification_results, - centroid=np.array([1.0, 1.0, 1.0]), - ) - - expected_axes = 2 - assert len(fig.axes) == expected_axes - fig.canvas.draw() - - -def test_plot_distance_distributions_returns_figure() -> None: - classification_results = { - "ShellAssignments": np.array([0, 0, 1, 1]), - "DistancesFromCenter": np.array([0.1, 0.2, 0.8, 0.9]), - "DistancesFromExterior": np.array([0.9, 0.8, 0.2, 0.1]), - "ShellsUsed": 2, - } - - fig = neighbors_module.plot_distance_distributions(classification_results) - - expected_axes = 2 - assert len(fig.axes) == expected_axes - fig.canvas.draw() diff --git a/tests/featurization/test_texture.py b/tests/featurization/test_texture.py deleted file mode 100644 index a972006..0000000 --- a/tests/featurization/test_texture.py +++ /dev/null @@ -1,228 +0,0 @@ -import sys -import types -from typing import Never - -import numpy as np -import pytest -from _pytest.monkeypatch import MonkeyPatch - -# Temporary import shim for legacy texture type import path. -if "zedprofiler.IO" not in sys.modules: - sys.modules["zedprofiler.IO"] = types.ModuleType("zedprofiler.IO") -if "zedprofiler.IO.loading_classes" not in sys.modules: - loading_classes_stub = types.ModuleType("zedprofiler.IO.loading_classes") - - class _ObjectLoaderStub: - pass - - loading_classes_stub.ObjectLoader = _ObjectLoaderStub - sys.modules["zedprofiler.IO.loading_classes"] = loading_classes_stub - -from zedprofiler.featurization import texture - - -class DummyObjectLoader: - def __init__( - self, - image: np.ndarray, - label_image: np.ndarray, - object_ids: np.ndarray, - ) -> None: - self.image = image - self.label_image = label_image - self.object_ids = object_ids - - -FEATURE_COUNT = 13 -N_DIRECTIONS = 13 -EXPECTED_DISTANCE = 2 -FIRST_OBJECT_ID = 1 -SECOND_OBJECT_ID = 2 -THIRD_OBJECT_ID = 3 -EXPECTED_OBJECT_COUNT = 2 -CONSTANT_IMAGE_VALUE = 7 -MISSING_OBJECT_ID = 99 - - -def test_scale_image_constant_returns_zeros_uint8() -> None: - image = np.full((2, 3, 4), CONSTANT_IMAGE_VALUE, dtype=np.int16) - - out = texture.scale_image(image, num_gray_levels=256) - - assert out.dtype == np.uint8 - assert out.shape == image.shape - assert np.all(out == CONSTANT_IMAGE_VALUE) - - -def test_scale_image_maps_min_max_to_requested_levels() -> None: - image = np.array([0, 1023], dtype=np.int32) - - out = texture.scale_image(image, num_gray_levels=256) - - np.testing.assert_array_equal(out, np.array([0, 255], dtype=np.uint8)) - - -def test_compute_texture_returns_expected_schema_and_lengths( - monkeypatch: MonkeyPatch, -) -> None: - image = np.arange(3 * 3 * 3, dtype=np.uint16).reshape((3, 3, 3)) - labels = np.zeros((3, 3, 3), dtype=np.int32) - labels[0, 0, 0] = FIRST_OBJECT_ID - labels[2, 2, 2] = SECOND_OBJECT_ID - loader = DummyObjectLoader( - image=image, - label_image=labels, - object_ids=np.array([FIRST_OBJECT_ID, SECOND_OBJECT_ID]), - ) - - fake_har = np.tile(np.arange(FEATURE_COUNT, dtype=float), (N_DIRECTIONS, 1)) - - def fake_haralick( - *, - ignore_zeros: bool, - f: np.ndarray, - distance: int, - compute_14th_feature: bool, - ) -> np.ndarray: - assert ignore_zeros is True - assert compute_14th_feature is False - assert distance == EXPECTED_DISTANCE - assert f.dtype == np.uint8 - return fake_har - - monkeypatch.setattr(texture.mahotas.features, "haralick", fake_haralick) - - out = texture.compute_texture(loader, distance=EXPECTED_DISTANCE, grayscale=256) - - assert set(out.keys()) == {"object_id", "texture_name", "texture_value"} - expected_total = EXPECTED_OBJECT_COUNT * FEATURE_COUNT * N_DIRECTIONS - assert len(out["object_id"]) == expected_total - assert len(out["texture_name"]) == expected_total - assert len(out["texture_value"]) == expected_total - - assert all( - name.endswith("-00-256") for name in out["texture_name"][0:FEATURE_COUNT] - ) - assert all( - name.endswith("-12-256") - for name in out["texture_name"][ - FEATURE_COUNT * EXPECTED_OBJECT_COUNT * (N_DIRECTIONS - 1) : FEATURE_COUNT - * EXPECTED_OBJECT_COUNT - * N_DIRECTIONS - ] - ) - values = np.array(out["texture_value"], dtype=float) - if not np.isfinite(values).all(): - pytest.xfail( - "Current texture implementation emits uninitialized values; " - "expected fixed in new texture code" - ) - expected = np.arange(FEATURE_COUNT, dtype=float) - first_block = np.array(out["texture_value"][:FEATURE_COUNT], dtype=float) - second_block = np.array( - out["texture_value"][FEATURE_COUNT : FEATURE_COUNT * EXPECTED_OBJECT_COUNT], - dtype=float, - ) - if (not np.allclose(first_block, expected)) or ( - not np.allclose(second_block, expected) - ): - pytest.xfail( - "Current texture implementation returns non-deterministic feature " - "values; expected fixed in new texture code" - ) - - np.testing.assert_allclose(first_block, expected) - np.testing.assert_allclose(second_block, expected) - - -def test_compute_texture_valueerror_from_haralick_yields_nan_values( - monkeypatch: MonkeyPatch, -) -> None: - image = np.ones((2, 2, 2), dtype=np.uint16) - labels = np.zeros((2, 2, 2), dtype=np.int32) - labels[0, 0, 0] = THIRD_OBJECT_ID - loader = DummyObjectLoader( - image=image, - label_image=labels, - object_ids=np.array([THIRD_OBJECT_ID]), - ) - - def raise_value_error(**kwargs: object) -> Never: - assert isinstance(kwargs, dict) - raise ValueError("haralick failed") - - monkeypatch.setattr(texture.mahotas.features, "haralick", raise_value_error) - - try: - out = texture.compute_texture(loader) - except TypeError: - pytest.xfail( - "Current texture implementation raises TypeError on Haralick " - "ValueError; new code should return NaN features" - ) - - assert len(out["object_id"]) == FEATURE_COUNT * N_DIRECTIONS - assert out["object_id"] == [THIRD_OBJECT_ID] * (FEATURE_COUNT * N_DIRECTIONS) - assert np.isnan(np.array(out["texture_value"], dtype=float)).all() - - -def test_compute_texture_skips_object_ids_not_present( - monkeypatch: MonkeyPatch, -) -> None: - image = np.arange(8, dtype=np.uint16).reshape((2, 2, 2)) - labels = np.zeros((2, 2, 2), dtype=np.int32) - labels[0, 0, 0] = FIRST_OBJECT_ID - loader = DummyObjectLoader( - image=image, - label_image=labels, - object_ids=np.array([FIRST_OBJECT_ID, MISSING_OBJECT_ID]), - ) - - def fake_haralick_all_ones(**kwargs: object) -> np.ndarray: - assert isinstance(kwargs, dict) - return np.ones((N_DIRECTIONS, FEATURE_COUNT), dtype=float) - - monkeypatch.setattr( - texture.mahotas.features, - "haralick", - fake_haralick_all_ones, - ) - - out = texture.compute_texture(loader) - - if MISSING_OBJECT_ID in set(out["object_id"]): - pytest.xfail( - "Current texture implementation does not skip missing object IDs; " - "new code should skip them" - ) - - assert len(out["object_id"]) == FEATURE_COUNT * N_DIRECTIONS - assert set(out["object_id"]) == {FIRST_OBJECT_ID} - - -def test_compute_texture_masks_non_object_voxels_inside_bbox( - monkeypatch: MonkeyPatch, -) -> None: - image = np.array([[[5, 9], [7, 11]]], dtype=np.uint16) # shape (1, 2, 2) - labels = np.array([[[1, 0], [0, 1]]], dtype=np.int32) # same bbox, sparse object - loader = DummyObjectLoader( - image=image, - label_image=labels, - object_ids=np.array([FIRST_OBJECT_ID]), - ) - - seen = {} - - def fake_haralick(*, f: np.ndarray, **kwargs: object) -> np.ndarray: - assert isinstance(kwargs, dict) - seen["f"] = f.copy() - return np.zeros((N_DIRECTIONS, FEATURE_COUNT), dtype=float) - - monkeypatch.setattr(texture.mahotas.features, "haralick", fake_haralick) - - texture.compute_texture(loader, grayscale=256, distance=1) - - assert "f" in seen - # Off-object voxels in the object's bbox should remain zero after masking/scaling - assert seen["f"][0, 0, 1] == 0 - assert seen["f"][0, 1, 0] == 0 diff --git a/tests/featurization/test_volumesizeshape.py b/tests/featurization/test_volumesizeshape.py deleted file mode 100644 index 7e20ea8..0000000 --- a/tests/featurization/test_volumesizeshape.py +++ /dev/null @@ -1,338 +0,0 @@ -"""Tests for volumesizeshape module compute behavior.""" - -from __future__ import annotations - -from typing import ClassVar - -import numpy as np -import pytest - -from zedprofiler.exceptions import ZedProfilerError -from zedprofiler.featurization import volumesizeshape - -# Test constants for coordinate ranges and object counts -NUM_TEST_OBJECTS = 2 -COORD_LOWER_BOUND = 1 -COORD_UPPER_BOUND = 3 -EXTENT_LOWER_BOUND = 0 -EXTENT_UPPER_BOUND = 1 - - -class DummyImageSetLoader: - """Minimal image set loader double for compute tests.""" - - anisotropy_spacing = (1.0, 1.0, 1.0) - - -class DummyObjectLoader: - """Minimal object loader double for compute tests.""" - - label_image = np.zeros((3, 3, 3), dtype=np.int32) - object_ids: ClassVar = [0] - - -def test_compute_no_args_returns_empty_schema() -> None: - """No-arg compute should be callable and return deterministic keys.""" - result = volumesizeshape.compute() - - assert isinstance(result, dict) - assert list(result.keys()) == [ - "object_id", - "Volume", - "CenterX", - "CenterY", - "CenterZ", - "BboxVolume", - "MinX", - "MaxX", - "MinY", - "MaxY", - "MinZ", - "MaxZ", - "Extent", - "EulerNumber", - "EquivalentDiameter", - "SurfaceArea", - ] - - -def test_compute_requires_both_loaders() -> None: - """Partial invocation should fail with a clear contract error.""" - with pytest.raises(ZedProfilerError): - volumesizeshape.compute(image_set_loader=DummyImageSetLoader()) - - with pytest.raises(ZedProfilerError): - volumesizeshape.compute(object_loader=DummyObjectLoader()) - - -def test_compute_delegates_when_loaders_provided( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Compute should execute delegated measurement path with both loaders.""" - sentinel = {"object_id": [], "Volume": []} - - def _fake_measure( - image_set_loader: DummyImageSetLoader, - object_loader: DummyObjectLoader, - ) -> dict[str, list[float]]: - assert isinstance(image_set_loader, DummyImageSetLoader) - assert isinstance(object_loader, DummyObjectLoader) - return sentinel - - monkeypatch.setattr(volumesizeshape, "measure_3D_volume_size_shape", _fake_measure) - - result = volumesizeshape.compute( - image_set_loader=DummyImageSetLoader(), - object_loader=DummyObjectLoader(), - ) - - assert result is sentinel - - -def test_empty_feature_result_schema() -> None: - """_empty_feature_result should return deterministic schema.""" - result = volumesizeshape._empty_feature_result() - - expected_keys = { - "object_id", - "Volume", - "CenterX", - "CenterY", - "CenterZ", - "BboxVolume", - "MinX", - "MaxX", - "MinY", - "MaxY", - "MinZ", - "MaxZ", - "Extent", - "EulerNumber", - "EquivalentDiameter", - "SurfaceArea", - } - - assert set(result.keys()) == expected_keys - - for value in result.values(): - assert isinstance(value, list) - assert len(value) == 0 - - -def test_get_skimage_measure_import() -> None: - """Test that _get_skimage_measure successfully imports skimage.measure.""" - try: - measure = volumesizeshape._get_skimage_measure() - # If scikit-image is available, check that it has expected functions - assert hasattr(measure, "marching_cubes") - assert hasattr(measure, "mesh_surface_area") - assert hasattr(measure, "regionprops") - except ZedProfilerError: - # If scikit-image is not available, that's also OK for this test - pytest.skip("scikit-image not available") - - -def test_get_skimage_measure_missing_raises_error( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Test that missing scikit-image raises appropriate error.""" - - def mock_import_module(name: str) -> object: - raise ModuleNotFoundError(f"No module named '{name}'") - - monkeypatch.setattr(volumesizeshape, "import_module", mock_import_module) - - with pytest.raises(ZedProfilerError, match="scikit-image"): - volumesizeshape._get_skimage_measure() - - -def test_calculate_surface_area_with_simple_data( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Test calculate_surface_area with mocked scikit-image.""" - # Create a simple 3D binary array with a small sphere-like region - label_object = np.zeros((7, 7, 7), dtype=np.uint8) - # Create a small ROI with gradient values for marching cubes - label_object[2:5, 2:5, 2:5] = np.array( - [ - [[0.0, 0.0, 0.0], [0.0, 0.5, 0.0], [0.0, 0.0, 0.0]], - [[0.0, 0.5, 0.0], [0.5, 1.0, 0.5], [0.0, 0.5, 0.0]], - [[0.0, 0.0, 0.0], [0.0, 0.5, 0.0], [0.0, 0.0, 0.0]], - ] - ) - - # Mock props for bounding box - props = { - "bbox-0": np.array([2]), - "bbox-1": np.array([2]), - "bbox-2": np.array([2]), - "bbox-3": np.array([5]), - "bbox-4": np.array([5]), - "bbox-5": np.array([5]), - } - - spacing = (1.0, 1.0, 1.0) - - try: - result = volumesizeshape.calculate_surface_area(label_object, props, spacing) - # Result should be a float (surface area) - assert isinstance(result, (float, np.floating)) - assert result >= 0 - except ZedProfilerError: - # If scikit-image is not available, skip - pytest.skip("scikit-image not available") - - -def test_measure_3d_volume_size_shape_returns_dict( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Test that measure_3D_volume_size_shape returns correct structure.""" - - # Mock the loaders - def mock_import_module(name: str) -> object: - raise ModuleNotFoundError("scikit-image not available for test") - - monkeypatch.setattr(volumesizeshape, "import_module", mock_import_module) - - # This should raise because _get_skimage_measure will fail - with pytest.raises(ZedProfilerError): - volumesizeshape.measure_3D_volume_size_shape( - DummyImageSetLoader(), - DummyObjectLoader(), - ) - - -def test_measure_3d_volume_size_shape_with_real_data() -> None: - """Test measure_3D_volume_size_shape with real scikit-image data.""" - try: - # Create a realistic 3D label image with two objects - label_image = np.zeros((10, 10, 10), dtype=np.int32) - # Object 1: a small cube - label_image[1:4, 1:4, 1:4] = 1 - # Object 2: another small cube - label_image[6:9, 6:9, 6:9] = 2 - - # Create real loaders - class RealImageSetLoader: - anisotropy_spacing = (1.0, 1.0, 1.0) - - class RealObjectLoader: - def __init__(self, label_image: np.ndarray) -> None: - self.label_image = label_image - self.object_ids = [1, 2] - - result = volumesizeshape.measure_3D_volume_size_shape( - RealImageSetLoader(), - RealObjectLoader(label_image), - ) - - # Verify structure - assert isinstance(result, dict) - - # Check all expected keys are present - expected_keys = { - "object_id", - "Volume", - "CenterX", - "CenterY", - "CenterZ", - "BboxVolume", - "MinX", - "MaxX", - "MinY", - "MaxY", - "MinZ", - "MaxZ", - "Extent", - "EulerNumber", - "EquivalentDiameter", - "SurfaceArea", - } - assert set(result.keys()) == expected_keys - - # Check that we have features for both objects - assert len(result["object_id"]) == NUM_TEST_OBJECTS - assert result["object_id"] == [1, 2] - # Verify all lists have same length - list_lengths = {k: len(v) for k, v in result.items()} - assert len(set(list_lengths.values())) == 1, "All lists should have same length" - - # Check Volume values are reasonable (23 voxels per 3x3x3 cube) - assert result["Volume"][0] > 0 - assert result["Volume"][1] > 0 - - # Check that center coordinates are within expected ranges - assert COORD_LOWER_BOUND <= result["CenterX"][0] <= COORD_UPPER_BOUND - assert COORD_LOWER_BOUND <= result["CenterY"][0] <= COORD_UPPER_BOUND - assert COORD_LOWER_BOUND <= result["CenterZ"][0] <= COORD_UPPER_BOUND - - # Check that extent values are between 0 and 1 - assert EXTENT_LOWER_BOUND <= result["Extent"][0] <= EXTENT_UPPER_BOUND - assert EXTENT_LOWER_BOUND <= result["Extent"][1] <= EXTENT_UPPER_BOUND - - except ZedProfilerError as e: - if "scikit-image" in str(e): - pytest.skip("scikit-image not available") - raise - - -def test_measure_3d_volume_size_shape_empty_objects() -> None: - """Test measure_3D_volume_size_shape with empty object list.""" - try: - # Create an empty label image - label_image = np.zeros((10, 10, 10), dtype=np.int32) - - class RealImageSetLoader: - anisotropy_spacing = (1.0, 1.0, 1.0) - - class RealObjectLoader: - def __init__(self, label_image: np.ndarray) -> None: - self.label_image = label_image - self.object_ids = [] # No objects - - result = volumesizeshape.measure_3D_volume_size_shape( - RealImageSetLoader(), - RealObjectLoader(label_image), - ) - - # Should return empty but valid structure - assert isinstance(result, dict) - for key, values in result.items(): - assert isinstance(values, list) - assert len(values) == 0 - - except ZedProfilerError as e: - if "scikit-image" in str(e): - pytest.skip("scikit-image not available") - raise - - -def test_measure_3d_volume_size_shape_with_anisotropic_spacing() -> None: - """Test measure_3D_volume_size_shape with anisotropic (non-uniform) spacing.""" - try: - # Create a label image - label_image = np.zeros((10, 10, 10), dtype=np.int32) - label_image[2:8, 2:8, 2:8] = 1 - - class AnisotropicImageSetLoader: - # Simulate higher resolution in Z axis - anisotropy_spacing = (0.5, 1.0, 1.0) - - class RealObjectLoader: - def __init__(self, label_image: np.ndarray) -> None: - self.label_image = label_image - self.object_ids = [1] - - result = volumesizeshape.measure_3D_volume_size_shape( - AnisotropicImageSetLoader(), - RealObjectLoader(label_image), - ) - - # Should handle anisotropic spacing correctly - assert len(result["object_id"]) == 1 - assert result["object_id"][0] == 1 - - except ZedProfilerError as e: - if "scikit-image" in str(e): - pytest.skip("scikit-image not available") - raise diff --git a/tests/test_contracts.py b/tests/test_contracts.py index e569c31..9d1019a 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -2,6 +2,7 @@ from __future__ import annotations +import tomllib from pathlib import Path import numpy as np @@ -32,6 +33,13 @@ from zedprofiler.exceptions import ContractError +def _load_toml_config(config_path: Path) -> dict[str, list[str]]: + """Load expected values from TOML file.""" + with open(config_path, "rb") as f: + toml_data = tomllib.load(f) + return toml_data.get("expected_values", {}) + + @pytest.fixture def expected_values_config_path(tmp_path: Path) -> Path: """Create a temporary expected-values TOML configuration file.""" @@ -107,10 +115,21 @@ def test_validate_image_array_type_contracts_rejects_non_numeric_dtype() -> None validate_image_array_type_contracts(arr) +@pytest.mark.parametrize( + argnames="channels,compartments", + argvalues=[ + (["DNA", "AGP", "ER"], ["Nuclei", "Cytoplasm", "Cell"]), + ], +) def test_expected_values_loads_config_and_adds_nochannel( - expected_values_config_path: Path, + channels: list[str], + compartments: list[str], ) -> None: - values = ExpectedFeatureNameValues(expected_values_config_path) + values = ExpectedFeatureNameValues( + compartments=compartments, + channels=channels, + ) + print(values) assert "Nuclei" in values.compartments assert "DNA" in values.channels @@ -121,78 +140,148 @@ def test_expected_values_loads_config_and_adds_nochannel( def test_expected_values_to_dict_returns_expected_keys( expected_values_config_path: Path, ) -> None: - values = ExpectedFeatureNameValues(expected_values_config_path).to_dict() + config = _load_toml_config(expected_values_config_path) + values = ExpectedFeatureNameValues( + compartments=config["compartments"], + channels=config["channels"], + ) + + result = values.expected_values_dict + assert set(result.keys()) == {"compartments", "channels", "features"} + + +def test_expected_values_accepts_expected_values_dict() -> None: + expected_values = { + "compartments": ["Nuclei", "Cytoplasm"], + "channels": ["DNA", "ER"], + "features": ["Intensity", "Texture"], + } + + values = ExpectedFeatureNameValues( + compartments=expected_values["compartments"], + channels=expected_values["channels"], + features=expected_values["features"], + ) - assert set(values.keys()) == {"compartments", "channels", "features"} + assert "NoChannel" in values.channels + assert set(values.compartments) == {"Nuclei", "Cytoplasm"} + assert "Intensity" in values.features + assert "Texture" in values.features def test_validate_column_name_schema_accepts_valid_feature_column( expected_values_config_path: Path, ) -> None: valid_name = "Nuclei_DNA_Intensity_MeanIntensity" + config = _load_toml_config(expected_values_config_path) - assert validate_column_name_schema(valid_name, expected_values_config_path) is True + assert ( + validate_column_name_schema( + valid_name, + channels=config["channels"], + compartments=config["compartments"], + ) + is True + ) def test_validate_column_name_schema_accepts_valid_metadata_column( expected_values_config_path: Path, ) -> None: valid_name = "Metadata_Storage_FilePath" + config = _load_toml_config(expected_values_config_path) - assert validate_column_name_schema(valid_name, expected_values_config_path) is True + assert ( + validate_column_name_schema( + valid_name, + channels=config["channels"], + compartments=config["compartments"], + ) + is True + ) def test_validate_column_name_schema_rejects_non_string_column_name( expected_values_config_path: Path, ) -> None: + config = _load_toml_config(expected_values_config_path) # Beartype catches type errors before function execution with pytest.raises(BeartypeCallHintParamViolation): - validate_column_name_schema(123, expected_values_config_path) # type: ignore[arg-type] + validate_column_name_schema( + 123, # type: ignore[arg-type] + channels=config["channels"], + compartments=config["compartments"], + ) def test_validate_column_name_schema_rejects_non_metadata_with_too_few_parts( expected_values_config_path: Path, ) -> None: invalid_name = "Nuclei_DNA_Intensity" + config = _load_toml_config(expected_values_config_path) with pytest.raises(ContractError): - validate_column_name_schema(invalid_name, expected_values_config_path) + validate_column_name_schema( + invalid_name, + channels=config["channels"], + compartments=config["compartments"], + ) def test_validate_column_name_schema_rejects_metadata_with_too_few_parts( expected_values_config_path: Path, ) -> None: invalid_name = "Metadata_Storage" + config = _load_toml_config(expected_values_config_path) with pytest.raises(ContractError): - validate_column_name_schema(invalid_name, expected_values_config_path) + validate_column_name_schema( + invalid_name, + channels=config["channels"], + compartments=config["compartments"], + ) def test_validate_column_name_schema_rejects_unknown_compartment( expected_values_config_path: Path, ) -> None: invalid_name = "Nucleus_DNA_Intensity_MeanIntensity" + config = _load_toml_config(expected_values_config_path) with pytest.raises(ContractError): - validate_column_name_schema(invalid_name, expected_values_config_path) + validate_column_name_schema( + invalid_name, + channels=config["channels"], + compartments=config["compartments"], + ) def test_validate_column_name_schema_rejects_unknown_channel( expected_values_config_path: Path, ) -> None: invalid_name = "Nuclei_GFP_Intensity_MeanIntensity" + config = _load_toml_config(expected_values_config_path) with pytest.raises(ContractError): - validate_column_name_schema(invalid_name, expected_values_config_path) + validate_column_name_schema( + invalid_name, + channels=config["channels"], + compartments=config["compartments"], + ) def test_validate_column_name_schema_rejects_unknown_feature( expected_values_config_path: Path, ) -> None: invalid_name = "Nuclei_DNA_UnknownFeature_MeanIntensity" + config = _load_toml_config(expected_values_config_path) with pytest.raises(ContractError): - validate_column_name_schema(invalid_name, expected_values_config_path) + validate_column_name_schema( + invalid_name, + channels=config["channels"], + compartments=config["compartments"], + ) def test_validate_return_schema_contract_accepts_valid_result() -> None: @@ -611,10 +700,18 @@ def test_column_name_validation_with_pydantic_and_schema( ) -> None: """Test column name validation combines Pydantic and schema checking.""" valid_name = "Nuclei_DNA_Intensity_MeanIntensity" + config = _load_toml_config(expected_values_config_path) # Pydantic model should parse successfully col_model = validate_column_name_with_pydantic(valid_name) assert col_model.compartment == "Nuclei" # Full schema validation should pass - assert validate_column_name_schema(valid_name, expected_values_config_path) is True + assert ( + validate_column_name_schema( + valid_name, + channels=config["channels"], + compartments=config["compartments"], + ) + is True + ) diff --git a/tests/test_integrations.py b/tests/test_integrations.py index c7fda2e..36383ba 100644 --- a/tests/test_integrations.py +++ b/tests/test_integrations.py @@ -8,8 +8,7 @@ import pandas as pd import pytest -from zedprofiler.exceptions import ZedProfilerError -from zedprofiler.featurization import areasizeshape +from zedprofiler.featurization import volumesizeshape from zedprofiler.IO.feature_writing_utils import ( FeatureMetadata, format_morphology_feature_name, @@ -33,7 +32,7 @@ def test_end_to_end_feature_extraction_and_save(self) -> None: # Create sample features features_df = pd.DataFrame( { - "object_id": [1, 2, 3], + "Metadata_Object_ObjectID": [1, 2, 3], "volume": [100.5, 200.3, 150.7], "diameter": [10.2, 12.5, 11.8], } @@ -104,7 +103,7 @@ def test_empty_dataframe_save_restore(self) -> None: # Create empty dataframe with proper schema empty_df = pd.DataFrame( { - "object_id": pd.Series([], dtype="int64"), + "Metadata_Object_ObjectID": pd.Series([], dtype="int64"), "feature1": pd.Series([], dtype="float64"), "feature2": pd.Series([], dtype="float64"), } @@ -133,16 +132,11 @@ def test_contract_validation_integration(self) -> None: assert isinstance(name1, str) assert len(name1) > 0 - def test_areasizeshape_schema_consistency(self) -> None: - """Test that areasizeshape maintains consistent output schema.""" - try: - result1 = areasizeshape.compute() - result2 = areasizeshape.compute() - result3 = areasizeshape.compute() - except ZedProfilerError as exc: - if "not implemented yet" in str(exc): - pytest.skip("areasizeshape.compute placeholder in current branch") - raise + def test_volumesizeshape_schema_consistency(self) -> None: + """Test that volumesizeshape maintains consistent output schema.""" + result1 = volumesizeshape.compute_volume_size_shape() + result2 = volumesizeshape.compute_volume_size_shape() + result3 = volumesizeshape.compute_volume_size_shape() # All calls should return same keys in same order assert list(result1.keys()) == list(result2.keys()) diff --git a/uv.lock b/uv.lock index ef69623..2ea277e 100644 --- a/uv.lock +++ b/uv.lock @@ -419,6 +419,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294 }, ] +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257 }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034 }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672 }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234 }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169 }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859 }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062 }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932 }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024 }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578 }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524 }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730 }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897 }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751 }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486 }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106 }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548 }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297 }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023 }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157 }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570 }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713 }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189 }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251 }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810 }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871 }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264 }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819 }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650 }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833 }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692 }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424 }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300 }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769 }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892 }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748 }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554 }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118 }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555 }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295 }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027 }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428 }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331 }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831 }, +] + [[package]] name = "coverage" version = "7.13.5" @@ -493,6 +548,24 @@ name = "cycler" version = "0.12.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321 }, +] + +[[package]] +name = "dask" +version = "2026.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "cloudpickle" }, + { name = "fsspec" }, + { name = "packaging" }, + { name = "partd" }, + { name = "pyyaml" }, + { name = "toolz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/2a/5d8cc1579590af86576dde890254440e478c7174b93a02095ecfc2e6ba38/dask-2026.3.0.tar.gz", hash = "sha256:f7d96c8274e8a900d217c1ff6ea8d1bbf0b4c2c21e74a409644498d925eb8f85", size = 11000710 } wheels = [ { url = "https://files.pythonhosted.org/packages/4a/f3/00bb1e867fba351e2d784170955713bee200c43ea306c59f30bd7e748192/dask-2026.3.0-py3-none-any.whl", hash = "sha256:be614b9242b0b38288060fb2d7696125946469c98a1c30e174883fd199e0428d", size = 1485630 }, ] @@ -617,6 +690,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/4c/93d0f85318da65923e4b91c1c2ff03d8a458cbefebe3bc612a6693c7906d/fire-0.7.1-py3-none-any.whl", hash = "sha256:e43fd8a5033a9001e7e2973bab96070694b9f12f2e0ecf96d4683971b5ab1882", size = 115945 }, ] +[[package]] +name = "flexcache" +version = "0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/b0/8a21e330561c65653d010ef112bf38f60890051d244ede197ddaa08e50c1/flexcache-0.3.tar.gz", hash = "sha256:18743bd5a0621bfe2cf8d519e4c3bfdf57a269c15d1ced3fb4b64e0ff4600656", size = 15816 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/cd/c883e1a7c447479d6e13985565080e3fea88ab5a107c21684c813dba1875/flexcache-0.3-py3-none-any.whl", hash = "sha256:d43c9fea82336af6e0115e308d9d33a185390b8346a017564611f1466dcd2e32", size = 13263 }, +] + +[[package]] +name = "flexparser" +version = "0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/99/b4de7e39e8eaf8207ba1a8fa2241dd98b2ba72ae6e16960d8351736d8702/flexparser-0.4.tar.gz", hash = "sha256:266d98905595be2ccc5da964fe0a2c3526fbbffdc45b65b3146d75db992ef6b2", size = 31799 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/5e/3be305568fe5f34448807976dc82fc151d76c3e0e03958f34770286278c1/flexparser-0.4-py3-none-any.whl", hash = "sha256:3738b456192dcb3e15620f324c447721023c0293f6af9955b481e91d00179846", size = 27625 }, +] + [[package]] name = "fonttools" version = "4.62.1" @@ -770,42 +867,28 @@ wheels = [ ] [[package]] -name = "imageio" -version = "2.37.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "pillow" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/84/93bcd1300216ea50811cee96873b84a1bebf8d0489ffaf7f2a3756bab866/imageio-2.37.3.tar.gz", hash = "sha256:bbb37efbfc4c400fcd534b367b91fcd66d5da639aaa138034431a1c5e0a41451", size = 389673 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl", hash = "sha256:46f5bb8522cd421c0f5ae104d8268f569d856b29eb1a13b92829d1970f32c9f0", size = 317646 }, -] - -[[package]] -name = "imageio" -version = "2.37.3" +name = "imagecodecs" +version = "2026.3.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/84/93bcd1300216ea50811cee96873b84a1bebf8d0489ffaf7f2a3756bab866/imageio-2.37.3.tar.gz", hash = "sha256:bbb37efbfc4c400fcd534b367b91fcd66d5da639aaa138034431a1c5e0a41451", size = 389673 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl", hash = "sha256:46f5bb8522cd421c0f5ae104d8268f569d856b29eb1a13b92829d1970f32c9f0", size = 317646 }, -] - -[[package]] -name = "imageio" -version = "2.37.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "pillow" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/84/93bcd1300216ea50811cee96873b84a1bebf8d0489ffaf7f2a3756bab866/imageio-2.37.3.tar.gz", hash = "sha256:bbb37efbfc4c400fcd534b367b91fcd66d5da639aaa138034431a1c5e0a41451", size = 389673 } +sdist = { url = "https://files.pythonhosted.org/packages/3b/8d/dc18623e5e926ad53c626e128c8baaf4ec42e41029cf0a07381cfef79289/imagecodecs-2026.3.6.tar.gz", hash = "sha256:471b8a4d1b3843cbf7179b45f7d7261f0c0b28809efc1ca6c47822477b143b85", size = 9565259 } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl", hash = "sha256:46f5bb8522cd421c0f5ae104d8268f569d856b29eb1a13b92829d1970f32c9f0", size = 317646 }, + { url = "https://files.pythonhosted.org/packages/f3/db/873d063c99a726d772bf6f076288da59bb12e9f2af3518c2e4de5fde234d/imagecodecs-2026.3.6-cp311-abi3-macosx_10_15_x86_64.whl", hash = "sha256:44cfb3b609d941014f8ac7cf8611b15ccfd7119443bbb6b5e53916b242d31f9e", size = 13953250 }, + { url = "https://files.pythonhosted.org/packages/42/84/36c38a82f033ffbc9e706dad32be7148f130fc00e7bb417ab60e063897a0/imagecodecs-2026.3.6-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:e64037f22980a211b17bf6bdf03f14ff459a7432eec24f7a58c342f6992132fa", size = 11697496 }, + { url = "https://files.pythonhosted.org/packages/45/fa/f67c4e644fdf06503e120f9d1c8d8654b99066dea7093a674b67704fa4a4/imagecodecs-2026.3.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:30fa140bb1a112a889926af36977214ed52a22e4557356043259b5e2f79cfba5", size = 25604431 }, + { url = "https://files.pythonhosted.org/packages/8f/29/93ea9cbab7f57b4e60480c51fc51d8e138e399d11797c981d5f6e79f9832/imagecodecs-2026.3.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:e30a14aa2e1c6c90e00375292726486c1d90bf003b1414d608ea4d1f62fd8a79", size = 26468592 }, + { url = "https://files.pythonhosted.org/packages/e8/a7/e3a89b2c516eaca7446e8f1335daeec90764b50888af5e073a2b6a987fcf/imagecodecs-2026.3.6-cp311-abi3-win32.whl", hash = "sha256:c972a45dfee1befbac048ba3492607003e9a185811e8febdc1ed531d48c07e75", size = 15597722 }, + { url = "https://files.pythonhosted.org/packages/22/c7/2b37a7fe9a2eb21011e50f046d62e68ac4e0f8d6ad94d7a10e9f8e8d685f/imagecodecs-2026.3.6-cp311-abi3-win_amd64.whl", hash = "sha256:e8fba5b9ac7be109ed35070208bc1683fa17cc381ed9535a4eae200c6d883bd8", size = 19177403 }, + { url = "https://files.pythonhosted.org/packages/c0/c7/94e930cef9e0a29a2df5e3ba3bacd2c2f1e34ca373fe48624b64af8ae91c/imagecodecs-2026.3.6-cp311-abi3-win_arm64.whl", hash = "sha256:fc4856913be6c8b3861223158920d934a0ae203149a435f585622dbbff8ed696", size = 15193605 }, + { url = "https://files.pythonhosted.org/packages/a3/0b/ba7ca5a14cf2ff744c47898ab9e98b6b96b364bce1459afcab5f4e5ea2bb/imagecodecs-2026.3.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ea2cd774854a3bfe1dc9508f98907edc877c02a78642ab371adbdec65a8865cc", size = 14335958 }, + { url = "https://files.pythonhosted.org/packages/18/d9/4750fb9739d474e399fa566b3a5c7033f2c9c0078bc869473b23d3e0d4b7/imagecodecs-2026.3.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6efa104ce600f61fef03a43daab34fe2953c8adb4e4ee6eb9544825f5361e5ab", size = 12021474 }, + { url = "https://files.pythonhosted.org/packages/29/d3/f0a71c797f836021241500d4a459b0f93317f2b5c9bcc39fb1eaa50e01ea/imagecodecs-2026.3.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:16fd83dd25d9c888317bc8cbbc5d7490b78c84c58275d80fb71ea7b28645276a", size = 29606531 }, + { url = "https://files.pythonhosted.org/packages/0c/8c/a1b433418306636fc22a94b669d7ebbbde8e3b26aef7d693c08f9f8a65af/imagecodecs-2026.3.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:39fcf671537cc92dd24ba4dcba257d8aa47e6f58018e839a5216c626f03d4126", size = 30039276 }, + { url = "https://files.pythonhosted.org/packages/ac/96/1474c0e242fb11d68304f858298971370fdfcecc404dbd9993a3ea449b4f/imagecodecs-2026.3.6-cp314-cp314t-win32.whl", hash = "sha256:65110ff30723a6e0b79c6c74ff52909a34b131366c4f76b1b68084cca5fcd982", size = 16345843 }, + { url = "https://files.pythonhosted.org/packages/d5/13/d0e09d8aaafe6fac1aa1bd8c0dcc0f024998ea4a23477128db14a085b4cf/imagecodecs-2026.3.6-cp314-cp314t-win_amd64.whl", hash = "sha256:1b810efeeb06bdcca07ae410a81bdb5eb9fb38f91689baf0f0b75783f5119a40", size = 20230890 }, + { url = "https://files.pythonhosted.org/packages/9c/06/3a5fe853e4df9a3655f8be81c1eb48013aab282c1557341b8634dd718e32/imagecodecs-2026.3.6-cp314-cp314t-win_arm64.whl", hash = "sha256:a2363c64e346cee4136f02a207b1b645729142f5260d122ddc3d5ff9e32ac6b1", size = 15911854 }, ] [[package]] @@ -1229,6 +1312,73 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/e4/3c356a9ea448a48caa5e44cd51293f7e896cd606f2ef86da96f5d61cc427/kerchunk-0.2.10-py3-none-any.whl", hash = "sha256:7fdaa77dae25c75d3ec9402c49208f37ae51d346ab082724e3e32608438f8c66", size = 68490 }, ] +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166 }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395 }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065 }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903 }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751 }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793 }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041 }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292 }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865 }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369 }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989 }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645 }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237 }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573 }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998 }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700 }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537 }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514 }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848 }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542 }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447 }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918 }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856 }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580 }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018 }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804 }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482 }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328 }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410 }, + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231 }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489 }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063 }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913 }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782 }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815 }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925 }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322 }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857 }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376 }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549 }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680 }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905 }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086 }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577 }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794 }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646 }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511 }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858 }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539 }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310 }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244 }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154 }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377 }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288 }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158 }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260 }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403 }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687 }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032 }, +] + [[package]] name = "lark" version = "1.3.1" @@ -1250,26 +1400,96 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl", hash = "sha256:ab0ea149e9c554d4ffeeb21105ac60bed7f3b4fd69b1d2360a4add51b170b005", size = 8044 }, ] +[[package]] +name = "locket" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/83/97b29fe05cb6ae28d2dbd30b81e2e402a3eed5f460c26e9eaa5895ceacf5/locket-1.0.0.tar.gz", hash = "sha256:5c0d4c052a8bbbf750e056a8e65ccd309086f4f0f18a2eac306a8dfa4112a632", size = 4350 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl", hash = "sha256:b6c819a722f7b6bd955b80781788e4a66a55628b858d347536b7e81325a3a5e3", size = 4398 }, +] + +[[package]] +name = "lxml" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/03/69347590f1cf4a6d5a4944bb6099e6d37f334784f16062234e1f892fdb1d/lxml-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a0092f2b107b69601adf562a57c956fbb596e05e3e6651cabd3054113b007e45", size = 8559689 }, + { url = "https://files.pythonhosted.org/packages/3f/58/25e00bb40b185c974cfe156c110474d9a8a8390d5f7c92a4e328189bb60e/lxml-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc7140d7a7386e6b545d41b7358f4d02b656d4053f5fa6859f92f4b9c2572c4d", size = 4617892 }, + { url = "https://files.pythonhosted.org/packages/f5/54/92ad98a94ac318dc4f97aaac22ff8d1b94212b2ae8af5b6e9b354bf825f7/lxml-6.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:419c58fc92cc3a2c3fa5f78c63dbf5da70c1fa9c1b25f25727ecee89a96c7de2", size = 4923489 }, + { url = "https://files.pythonhosted.org/packages/15/3b/a20aecfab42bdf4f9b390590d345857ad3ffd7c51988d1c89c53a0c73faf/lxml-6.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:37fabd1452852636cf38ecdcc9dd5ca4bba7a35d6c53fa09725deeb894a87491", size = 5082162 }, + { url = "https://files.pythonhosted.org/packages/45/26/2cdb3d281ac1bd175603e290cbe4bad6eff127c0f8de90bafd6f8548f0fd/lxml-6.1.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2853c8b2170cc6cd54a6b4d50d2c1a8a7aeca201f23804b4898525c7a152cfc", size = 4993247 }, + { url = "https://files.pythonhosted.org/packages/f6/05/d735aef963740022a08185c84821f689fc903acb3d50326e6b1e9886cc22/lxml-6.1.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e369cbd690e788c8d15e56222d91a09c6a417f49cbc543040cba0fe2e25a79e", size = 5613042 }, + { url = "https://files.pythonhosted.org/packages/ee/b8/ead7c10efff731738c72e59ed6eb5791854879fbed7ae98781a12006263a/lxml-6.1.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e69aa6805905807186eb00e66c6d97a935c928275182eb02ee40ba00da9623b2", size = 5228304 }, + { url = "https://files.pythonhosted.org/packages/6b/10/e9842d2ec322ea65f0a7270aa0315a53abed06058b88ef1b027f620e7a5f/lxml-6.1.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4bd1bdb8a9e0e2dd229de19b5f8aebac80e916921b4b2c6ef8a52bc131d0c1f9", size = 5341578 }, + { url = "https://files.pythonhosted.org/packages/89/54/40d9403d7c2775fa7301d3ddd3464689bfe9ba71acc17dfff777071b4fdc/lxml-6.1.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:cbd7b79cdcb4986ad78a2662625882747f09db5e4cd7b2ae178a88c9c51b3dfe", size = 4700209 }, + { url = "https://files.pythonhosted.org/packages/85/b2/bbdcc2cf45dfc7dfffef4fd97e5c47b15919b6a365247d95d6f684ef5e82/lxml-6.1.0-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:43e4d297f11080ec9d64a4b1ad7ac02b4484c9f0e2179d9c4ef78e886e747b88", size = 5232365 }, + { url = "https://files.pythonhosted.org/packages/48/5a/b06875665e53aaba7127611a7bed3b7b9658e20b22bc2dd217a0b7ab0091/lxml-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cc16682cc987a3da00aa56a3aa3075b08edb10d9b1e476938cfdbee8f3b67181", size = 5043654 }, + { url = "https://files.pythonhosted.org/packages/e9/9c/e71a069d09641c1a7abeb30e693f828c7c90a41cbe3d650b2d734d876f85/lxml-6.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d6d8efe71429635f0559579092bb5e60560d7b9115ee38c4adbea35632e7fa24", size = 4769326 }, + { url = "https://files.pythonhosted.org/packages/cc/06/7a9cd84b3d4ed79adf35f874750abb697dec0b4a81a836037b36e47c091a/lxml-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e39ab3a28af7784e206d8606ec0e4bcad0190f63a492bca95e94e5a4aef7f6e", size = 5635879 }, + { url = "https://files.pythonhosted.org/packages/cc/f0/9d57916befc1e54c451712c7ee48e9e74e80ae4d03bdce49914e0aee42cd/lxml-6.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9eb667bf50856c4a58145f8ca2d5e5be160191e79eb9e30855a476191b3c3495", size = 5224048 }, + { url = "https://files.pythonhosted.org/packages/99/75/90c4eefda0c08c92221fe0753db2d6699a4c628f76ff4465ec20dea84cc1/lxml-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7f4a77d6f7edf9230cee3e1f7f6764722a41604ee5681844f18db9a81ea0ec33", size = 5250241 }, + { url = "https://files.pythonhosted.org/packages/5e/73/16596f7e4e38fa33084b9ccbccc22a15f82a290a055126f2c1541236d2ff/lxml-6.1.0-cp313-cp313-win32.whl", hash = "sha256:28902146ffbe5222df411c5d19e5352490122e14447e98cd118907ee3fd6ee62", size = 3596938 }, + { url = "https://files.pythonhosted.org/packages/8e/63/981401c5680c1eb30893f00a19641ac80db5d1e7086c62cb4b13ed813038/lxml-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:4a1503c56e4e2b38dc76f2f2da7bae69670c0f1933e27cfa34b2fa5876410b16", size = 3995728 }, + { url = "https://files.pythonhosted.org/packages/e7/e8/c358a38ac3e541d16a1b527e4e9cb78c0419b0506a070ace11777e5e8404/lxml-6.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:e0af85773850417d994d019741239b901b22c6680206f46a34766926e466141d", size = 3658372 }, + { url = "https://files.pythonhosted.org/packages/eb/45/cee4cf203ef0bab5c52afc118da61d6b460c928f2893d40023cfa27e0b80/lxml-6.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ab863fd37458fed6456525f297d21239d987800c46e67da5ef04fc6b3dd93ac8", size = 8576713 }, + { url = "https://files.pythonhosted.org/packages/8a/a7/eda05babeb7e046839204eaf254cd4d7c9130ce2bbf0d9e90ea41af5654d/lxml-6.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6fd8b1df8254ff4fd93fd31da1fc15770bde23ac045be9bb1f87425702f61cc9", size = 4623874 }, + { url = "https://files.pythonhosted.org/packages/e7/e9/db5846de9b436b91890a62f29d80cd849ea17948a49bf532d5278ee69a9e/lxml-6.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:47024feaae386a92a146af0d2aeed65229bf6fff738e6a11dda6b0015fb8fd03", size = 4949535 }, + { url = "https://files.pythonhosted.org/packages/5a/ba/0d3593373dcae1d68f40dc3c41a5a92f2544e68115eb2f62319a4c2a6500/lxml-6.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3f00972f84450204cd5d93a5395965e348956aaceaadec693a22ec743f8ae3eb", size = 5086881 }, + { url = "https://files.pythonhosted.org/packages/43/76/759a7484539ad1af0d125a9afe9c3fb5f82a8779fd1f5f56319d9e4ea2fd/lxml-6.1.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97faa0860e13b05b15a51fb4986421ef7a30f0b3334061c416e0981e9450ca4c", size = 5031305 }, + { url = "https://files.pythonhosted.org/packages/dc/b9/c1f0daf981a11e47636126901fd4ab82429e18c57aeb0fc3ad2940b42d8b/lxml-6.1.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:972a6451204798675407beaad97b868d0c733d9a74dafefc63120b81b8c2de28", size = 5647522 }, + { url = "https://files.pythonhosted.org/packages/31/e6/1f533dcd205275363d9ba3511bcec52fa2df86abf8abe6a5f2c599f0dc31/lxml-6.1.0-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fe022f20bc4569ec66b63b3fb275a3d628d9d32da6326b2982584104db6d3086", size = 5239310 }, + { url = "https://files.pythonhosted.org/packages/c3/8c/4175fb709c78a6e315ed814ed33be3defd8b8721067e70419a6cf6f971da/lxml-6.1.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:75c4c7c619a744f972f4451bf5adf6d0fb00992a1ffc9fd78e13b0bc817cc99f", size = 5350799 }, + { url = "https://files.pythonhosted.org/packages/fd/77/6ffdebc5994975f0dde4acb59761902bd9d9bb84422b9a0bd239a7da9ca8/lxml-6.1.0-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3648f20d25102a22b6061c688beb3a805099ea4beb0a01ce62975d926944d292", size = 4697693 }, + { url = "https://files.pythonhosted.org/packages/f8/f1/565f36bd5c73294602d48e04d23f81ff4c8736be6ba5e1d1ec670ac9be80/lxml-6.1.0-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77b9f99b17cbf14026d1e618035077060fc7195dd940d025149f3e2e830fbfcb", size = 5250708 }, + { url = "https://files.pythonhosted.org/packages/5a/11/a68ab9dd18c5c499404deb4005f4bc4e0e88e5b72cd755ad96efec81d18d/lxml-6.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32662519149fd7a9db354175aa5e417d83485a8039b8aaa62f873ceee7ea4cad", size = 5084737 }, + { url = "https://files.pythonhosted.org/packages/ab/78/e8f41e2c74f4af564e6a0348aea69fb6daaefa64bc071ef469823d22cc18/lxml-6.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:73d658216fc173cf2c939e90e07b941c5e12736b0bf6a99e7af95459cfe8eabb", size = 4737817 }, + { url = "https://files.pythonhosted.org/packages/06/2d/aa4e117aa2ce2f3b35d9ff246be74a2f8e853baba5d2a92c64744474603a/lxml-6.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ac4db068889f8772a4a698c5980ec302771bb545e10c4b095d4c8be26749616f", size = 5670753 }, + { url = "https://files.pythonhosted.org/packages/08/f5/dd745d50c0409031dbfcc4881740542a01e54d6f0110bd420fa7782110b8/lxml-6.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:45e9dfbd1b661eb64ba0d4dbe762bd210c42d86dd1e5bd2bdf89d634231beb43", size = 5238071 }, + { url = "https://files.pythonhosted.org/packages/3e/74/ad424f36d0340a904665867dab310a3f1f4c96ff4039698de83b77f44c1f/lxml-6.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:89e8d73d09ac696a5ba42ec69787913d53284f12092f651506779314f10ba585", size = 5264319 }, + { url = "https://files.pythonhosted.org/packages/53/36/a15d8b3514ec889bfd6aa3609107fcb6c9189f8dc347f1c0b81eded8d87c/lxml-6.1.0-cp314-cp314-win32.whl", hash = "sha256:ebe33f4ec1b2de38ceb225a1749a2965855bffeef435ba93cd2d5d540783bf2f", size = 3657139 }, + { url = "https://files.pythonhosted.org/packages/1a/a4/263ebb0710851a3c6c937180a9a86df1206fdfe53cc43005aa2237fd7736/lxml-6.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:398443df51c538bd578529aa7e5f7afc6c292644174b47961f3bf87fe5741120", size = 4064195 }, + { url = "https://files.pythonhosted.org/packages/80/68/2000f29d323b6c286de077ad20b429fc52272e44eae6d295467043e56012/lxml-6.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:8c8984e1d8c4b3949e419158fda14d921ff703a9ed8a47236c6eb7a2b6cb4946", size = 3741870 }, + { url = "https://files.pythonhosted.org/packages/30/e9/21383c7c8d43799f0da90224c0d7c921870d476ec9b3e01e1b2c0b8237c5/lxml-6.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1081dd10bc6fa437db2500e13993abf7cc30716d0a2f40e65abb935f02ec559c", size = 8827548 }, + { url = "https://files.pythonhosted.org/packages/a5/01/c6bc11cd587030dd4f719f65c5657960649fe3e19196c844c75bf32cd0d6/lxml-6.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:dabecc48db5f42ba348d1f5d5afdc54c6c4cc758e676926c7cd327045749517d", size = 4735866 }, + { url = "https://files.pythonhosted.org/packages/f3/01/757132fff5f4acf25463b5298f1a46099f3a94480b806547b29ce5e385de/lxml-6.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e3dd5fe19c9e0ac818a9c7f132a5e43c1339ec1cbbfecb1a938bd3a47875b7c9", size = 4969476 }, + { url = "https://files.pythonhosted.org/packages/fd/fb/1bc8b9d27ed64be7c8903db6c89e74dc8c2cd9ec630a7462e4654316dc5b/lxml-6.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9e7b0a4ca6dcc007a4cef00a761bba2dea959de4bd2df98f926b33c92ca5dfb9", size = 5103719 }, + { url = "https://files.pythonhosted.org/packages/d5/e7/5bf82fa28133536a54601aae633b14988e89ed61d4c1eb6b899b023233aa/lxml-6.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d27bbe326c6b539c64b42638b18bc6003a8d88f76213a97ac9ed4f885efeab7", size = 5027890 }, + { url = "https://files.pythonhosted.org/packages/2d/20/e048db5d4b4ea0366648aa595f26bb764b2670903fc585b87436d0a5032c/lxml-6.1.0-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4e425db0c5445ef0ad56b0eec54f89b88b2d884656e536a90b2f52aecb4ca86", size = 5596008 }, + { url = "https://files.pythonhosted.org/packages/9a/c2/d10807bc8da4824b39e5bd01b5d05c077b6fd01bd91584167edf6b269d22/lxml-6.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b89b098105b8599dc57adac95d1813409ac476d3c948a498775d3d0c6124bfb", size = 5224451 }, + { url = "https://files.pythonhosted.org/packages/3c/15/2ebea45bea427e7f0057e9ce7b2d62c5aba20c6b001cca89ed0aadb3ad41/lxml-6.1.0-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:c4a699432846df86cc3de502ee85f445ebad748a1c6021d445f3e514d2cd4b1c", size = 5312135 }, + { url = "https://files.pythonhosted.org/packages/31/e2/87eeae151b0be2a308d49a7ec444ff3eb192b14251e62addb29d0bf3778f/lxml-6.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:30e7b2ed63b6c8e97cca8af048589a788ab5c9c905f36d9cf1c2bb549f450d2f", size = 4639126 }, + { url = "https://files.pythonhosted.org/packages/a3/51/8a3f6a20902ad604dd746ec7b4000311b240d389dac5e9d95adefd349e0c/lxml-6.1.0-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:022981127642fe19866d2907d76241bb07ed21749601f727d5d5dd1ce5d1b773", size = 5232579 }, + { url = "https://files.pythonhosted.org/packages/6d/d2/650d619bdbe048d2c3f2c31edb00e35670a5e2d65b4fe3b61bce37b19121/lxml-6.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:23cad0cc86046d4222f7f418910e46b89971c5a45d3c8abfad0f64b7b05e4a9b", size = 5084206 }, + { url = "https://files.pythonhosted.org/packages/dd/8a/672ca1a3cbeabd1f511ca275a916c0514b747f4b85bdaae103b8fa92f307/lxml-6.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:21c3302068f50d1e8728c67c87ba92aa87043abee517aa2576cca1855326b405", size = 4758906 }, + { url = "https://files.pythonhosted.org/packages/be/f1/ef4b691da85c916cb2feb1eec7414f678162798ac85e042fa164419ac05c/lxml-6.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:be10838781cb3be19251e276910cd508fe127e27c3242e50521521a0f3781690", size = 5620553 }, + { url = "https://files.pythonhosted.org/packages/59/17/94e81def74107809755ac2782fdad4404420f1c92ca83433d117a6d5acf0/lxml-6.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2173a7bffe97667bbf0767f8a99e587740a8c56fdf3befac4b09cb29a80276fd", size = 5229458 }, + { url = "https://files.pythonhosted.org/packages/21/55/c4be91b0f830a871fc1b0d730943d56013b683d4671d5198260e2eae722b/lxml-6.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c6854e9cf99c84beb004eecd7d3a3868ef1109bf2b1df92d7bc11e96a36c2180", size = 5247861 }, + { url = "https://files.pythonhosted.org/packages/c2/ca/77123e4d77df3cb1e968ade7b1f808f5d3a5c1c96b18a33895397de292c1/lxml-6.1.0-cp314-cp314t-win32.whl", hash = "sha256:00750d63ef0031a05331b9223463b1c7c02b9004cef2346a5b2877f0f9494dd2", size = 3897377 }, + { url = "https://files.pythonhosted.org/packages/64/ce/3554833989d074267c063209bae8b09815e5656456a2d332b947806b05ff/lxml-6.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:80410c3a7e3c617af04de17caa9f9f20adaa817093293d69eae7d7d0522836f5", size = 4392701 }, + { url = "https://files.pythonhosted.org/packages/2b/a0/9b916c68c0e57752c07f8f64b30138d9d4059dbeb27b90274dedbea128ff/lxml-6.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:26dd9f57ee3bd41e7d35b4c98a2ffd89ed11591649f421f0ec19f67d50ec67ac", size = 3817120 }, +] + [[package]] name = "mahotas" version = "1.4.18" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f6/71/bf99df8458c0ca05cb9a16f400e66c09b37b15ea124aaa3becb577555cc5/mahotas-1.4.18.tar.gz", hash = "sha256:e6bd2eea4143a24f381b30c64078503cd8ffa20ca493e39ffa29f9d024d9cf8b", size = 1533222 } [[package]] name = "markdown-it-py" -version = "4.1.0" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5c/5c/f3aedc83549aae71cd52b9e9687fe896e3dc6e966ba20eba04718605d198/markdown_it_py-4.1.0.tar.gz", hash = "sha256:760e3f87b2787c044c5138a5ba107b7c2be26c03b13cc7f8fe42756b65b1df6c", size = 81613 } +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/88/802c82060c54bc7dde21eb0033e337838b8181a1323254aa9ec41cbfc3d1/markdown_it_py-4.1.0-py3-none-any.whl", hash = "sha256:d4939a62a2dd0cd9cb80a191a711ba1d39bac8ed5ef9e9966895b0171c01c46d", size = 90955 }, + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687 }, ] [[package]] @@ -1324,6 +1544,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146 }, ] +[[package]] +name = "matplotlib" +version = "3.10.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/d3/8d4f6afbecb49fc04e060a57c0fce39ea51cc163a6bd87303ccd698e4fa6/matplotlib-3.10.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b580440f1ff81a0e34122051a3dfabb7e4b7f9e380629929bde0eff9af72165f", size = 8320331 }, + { url = "https://files.pythonhosted.org/packages/63/d9/9e14bc7564bf92d5ffa801ae5fac819ce74b925dfb55e3ebde61a3bbad3e/matplotlib-3.10.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b1b745c489cd1a77a0dc1120a05dc87af9798faebc913601feb8c73d89bf2d1e", size = 8216461 }, + { url = "https://files.pythonhosted.org/packages/8a/17/4402d0d14ccf1dfc70932600b68097fbbf9c898a4871d2cbbe79c7801a32/matplotlib-3.10.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f", size = 8790091 }, + { url = "https://files.pythonhosted.org/packages/3e/0b/322aeec06dd9b91411f92028b37d447342770a24392aa4813e317064dad5/matplotlib-3.10.9-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a8d66a55def891c33147ba3ba9bfcabf0b526a43764c818acbb4525e5ed0838", size = 9605027 }, + { url = "https://files.pythonhosted.org/packages/74/88/5f13482f55e7b00bcfc09838b093c2456e1379978d2a146844aae05350ad/matplotlib-3.10.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d843374407c4017a6403b59c6c81606773d136f3259d5b6da3131bc814542cc2", size = 9671269 }, + { url = "https://files.pythonhosted.org/packages/c5/e0/0840fd2f93da988ec660b8ad1984abe9f25d2aed22a5e394ff1c68c88307/matplotlib-3.10.9-cp313-cp313-win_amd64.whl", hash = "sha256:f4399f64b3e94cd500195490972ae1ee81170df1636fa15364d157d5bdd7b921", size = 8217588 }, + { url = "https://files.pythonhosted.org/packages/47/b9/d706d06dd605c49b9f83a2aed8c13e3e5db70697d7a80b7e3d7915de6b17/matplotlib-3.10.9-cp313-cp313-win_arm64.whl", hash = "sha256:ba7b3b8ef09eab7df0e86e9ae086faa433efbfbdb46afcb3aa16aabf779469a8", size = 8136913 }, + { url = "https://files.pythonhosted.org/packages/9b/45/6e32d96978264c8ca8c4b1010adb955a1a49cfaf314e212bbc8908f04a61/matplotlib-3.10.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:09218df8a93712bd6ea133e83a153c755448cf7868316c531cffcc43f69d1cc9", size = 8368019 }, + { url = "https://files.pythonhosted.org/packages/86/0a/c8e3d3bba245f0f7fc424937f8ff7ef77291a36af3edb97ccd78aa93d84f/matplotlib-3.10.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:82368699727bfb7b0182e1aa13082e3c08e092fa1a25d3e1fd92405bff96f6d4", size = 8264645 }, + { url = "https://files.pythonhosted.org/packages/3d/aa/5bf5a14fe4fed73a4209a155606f8096ff797aad89c6c35179026571133e/matplotlib-3.10.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3225f4e1edcb8c86c884ddf79ebe20ecd0a67d30188f279897554ccd8fded4dc", size = 8802194 }, + { url = "https://files.pythonhosted.org/packages/dd/5e/b4be852d6bba6fd15893fadf91ff26ae49cb91aac789e95dde9d342e664f/matplotlib-3.10.9-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de2445a0c6690d21b7eb6ce071cebad6d40a2e9bdf10d039074a96ba19797b99", size = 9622684 }, + { url = "https://files.pythonhosted.org/packages/4c/3d/ed428c971139112ef730f62770654d609467346d09d4b62617e1afd68a5a/matplotlib-3.10.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d", size = 9680790 }, + { url = "https://files.pythonhosted.org/packages/e7/09/052e884aaf2b985c63cb79f715f1d5b6a3eaa7de78f6a52b9dbc077d5b53/matplotlib-3.10.9-cp313-cp313t-win_amd64.whl", hash = "sha256:e9fae004b941b23ff2edcf1567a857ed77bafc8086ffa258190462328434faf8", size = 8287571 }, + { url = "https://files.pythonhosted.org/packages/f4/38/ae27288e788c35a4250491422f3db7750366fc8c97d6f36fbdecfc1f5518/matplotlib-3.10.9-cp313-cp313t-win_arm64.whl", hash = "sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38", size = 8188292 }, + { url = "https://files.pythonhosted.org/packages/d6/e6/3bd8afd04949f02eabc1c17115ea5255e19cacd4d06fc5abdde4eeb0052c/matplotlib-3.10.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d", size = 8321276 }, + { url = "https://files.pythonhosted.org/packages/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f", size = 8218218 }, + { url = "https://files.pythonhosted.org/packages/85/8f/becc9722cafc64f5d2eb0b7c1bf5f585271c618a45dbd8fabeb021f898b6/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b", size = 9608145 }, + { url = "https://files.pythonhosted.org/packages/32/5d/f7e914f7d9325abff4057cee62c0fa70263683189f774473cbfb534cd13b/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2", size = 9885085 }, + { url = "https://files.pythonhosted.org/packages/a5/fd/fa69f2221534e80cc5772ac2b7d222011a2acafc2ec7216d5dd174c864ae/matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716", size = 9672358 }, + { url = "https://files.pythonhosted.org/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.whl", hash = "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f", size = 8349970 }, + { url = "https://files.pythonhosted.org/packages/64/dc/95d60ecaefe30680a154b52ea96ab4b0dab547f1fd6aa12f5fb655e89cae/matplotlib-3.10.9-cp314-cp314-win_arm64.whl", hash = "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456", size = 8272785 }, + { url = "https://files.pythonhosted.org/packages/70/a0/005d68bc8b8418300ce6591f18586910a8526806e2ab663933d9f20a41e9/matplotlib-3.10.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe", size = 8367999 }, + { url = "https://files.pythonhosted.org/packages/22/05/1236cc9290be70b2498af20ca348add76e3fffe7f67b477db5133a84f3ea/matplotlib-3.10.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6", size = 8264543 }, + { url = "https://files.pythonhosted.org/packages/cd/c2/071f5a5ff6c5bd63aaaf2f45c811d9bf2ced94bde188d9e1a519e21d0cba/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c", size = 9622800 }, + { url = "https://files.pythonhosted.org/packages/95/57/da7d1f10a85624b9e7db68e069dd94e58dc41dbf9463c5921632ecbe3661/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4", size = 9888561 }, + { url = "https://files.pythonhosted.org/packages/67/b2/ef8d6bb59b0edb6c16c968b70f548aa13b54348972def5aa6ac85df67145/matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf", size = 9680884 }, + { url = "https://files.pythonhosted.org/packages/61/1c/d21bfeb9931881ebe96bcfcff27c7ae4b160ae0ec291a714c42641a56d75/matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39", size = 8432333 }, + { url = "https://files.pythonhosted.org/packages/78/23/92493c3e6e1b635ccfff146f7b99e674808787915420373ac399283764c2/matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c", size = 8324785 }, +] + [[package]] name = "matplotlib-inline" version = "0.2.1" @@ -1338,14 +1605,14 @@ wheels = [ [[package]] name = "mdit-py-plugins" -version = "0.5.0" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/3d/e0e8d9d1cee04f758120915e2b2a3a07eb41f8cf4654b4734788a522bcd1/mdit_py_plugins-0.6.0.tar.gz", hash = "sha256:2436f14a7295837ac9228a36feeabda867c4abc488c8d019ad5c0bda88eee040", size = 56025 } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205 }, + { url = "https://files.pythonhosted.org/packages/71/d6/48f5b9e44e2e760855d7b489b1317cd7620e82dcb73197961e5cc1391348/mdit_py_plugins-0.6.0-py3-none-any.whl", hash = "sha256:f7e7a25d8b616fee99cb1e330da73451d11a8061baf39bb9663ab9ce0e005b90", size = 66655 }, ] [[package]] @@ -1816,6 +2083,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579 }, ] +[[package]] +name = "pint" +version = "0.25.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flexcache" }, + { name = "flexparser" }, + { name = "platformdirs" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/9d/b1379cdbd33a49d17d627bc24e2b63cca06a1c5343b38072d2889499e82e/pint-0.25.3.tar.gz", hash = "sha256:f8f5df6cf65314d74da1ade1bf96f8e3e4d0c41b51577ac53c49e7d44ca5acee", size = 255106 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/dd/a9fe6a0a09512da23951c68bf36466aeecd89def3183dc095edbc807ddc5/pint-0.25.3-py3-none-any.whl", hash = "sha256:27eb25143bd5de9fcc4d5a4b484f16faf6b4615aa93ece6b3373a8c1a3c1b97d", size = 307488 }, +] + [[package]] name = "platformdirs" version = "4.9.6" @@ -2070,6 +2352,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, ] +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781 }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -2502,6 +2793,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165 }, ] +[[package]] +name = "semver" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size = 269730 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912 }, +] + [[package]] name = "send2trash" version = "2.1.0" @@ -2744,6 +3044,13 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b4/af/ce4df3ca29122d219c45d3e86e5ff9a9df03b8cf31afd76817b662c803a3/tifffile-2026.5.2-py3-none-any.whl", hash = "sha256:5129b53b826e768a5b1af26b765eeea75c2d0a227d2d12849617e0737588e105", size = 266420 }, ] +[package.optional-dependencies] +zarr = [ + { name = "fsspec" }, + { name = "kerchunk" }, + { name = "zarr" }, +] + [[package]] name = "tinycss2" version = "1.4.0" @@ -2828,15 +3135,15 @@ wheels = [ ] [[package]] -name = "traitlets" -version = "5.14.3" +name = "typeguard" +version = "4.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598 } +sdist = { url = "https://files.pythonhosted.org/packages/2b/e8/66e25efcc18542d58706ce4e50415710593721aae26e794ab1dec34fb66f/typeguard-4.5.1.tar.gz", hash = "sha256:f6f8ecbbc819c9bc749983cc67c02391e16a9b43b8b27f15dc70ed7c4a007274", size = 80121 } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374 }, + { url = "https://files.pythonhosted.org/packages/91/88/b55b3117287a8540b76dbdd87733808d4d01c8067a3b339408c250bb3600/typeguard-4.5.1-py3-none-any.whl", hash = "sha256:44d2bf329d49a244110a090b55f5f91aa82d9a9834ebfd30bcc73651e4a8cc40", size = 36745 }, ] [[package]] @@ -2848,6 +3155,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, ] +[[package]] +name = "typing-inspect" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827 }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, +] + [[package]] name = "tzdata" version = "2026.2" @@ -2909,11 +3241,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556 } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602 } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584 }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087 }, ] [[package]] @@ -3005,6 +3337,7 @@ dependencies = [ { name = "bioio-tifffile" }, { name = "fire" }, { name = "jinja2" }, + { name = "mahotas" }, { name = "matplotlib" }, { name = "pandas" }, { name = "pandera" }, @@ -3041,6 +3374,7 @@ requires-dist = [ { name = "bioio-tifffile", specifier = ">=1.3" }, { name = "fire", specifier = ">=0.7.1" }, { name = "jinja2", specifier = ">=3.1.6" }, + { name = "mahotas", specifier = ">=1.4.18" }, { name = "matplotlib", specifier = ">=3.10.8" }, { name = "pandas", specifier = ">=3.0.2" }, { name = "pandera", specifier = ">=0.31.1" }, From 3f7390bdf59ddc680722e71f99987b1fef10a101 Mon Sep 17 00:00:00 2001 From: MikeLippincott <1michaell2017@gmail.com> Date: Mon, 25 May 2026 14:36:30 -0600 Subject: [PATCH 2/2] integrating modules --- .pre-commit-config.yaml | 2 +- README.md | 2 +- ROADMAP.md | 10 +- justfile | 5 +- src/zedprofiler/featurization/granularity.py | 3 +- tests/featurization/conftest.py | 9 + tests/featurization/test_colocalization.py | 143 ++++++++++++++ tests/featurization/test_granularity.py | 182 ++++++++++++++++++ tests/featurization/test_intensity.py | 59 ++++++ tests/featurization/test_neighbors.py | 154 +++++++++++++++ .../test_neighbors_additional.py | 161 ++++++++++++++++ tests/featurization/test_texture.py | 58 ++++++ tests/featurization/test_volumesizeshape.py | 75 ++++++++ 13 files changed, 853 insertions(+), 10 deletions(-) create mode 100644 tests/featurization/conftest.py create mode 100644 tests/featurization/test_colocalization.py create mode 100644 tests/featurization/test_granularity.py create mode 100644 tests/featurization/test_intensity.py create mode 100644 tests/featurization/test_neighbors.py create mode 100644 tests/featurization/test_neighbors_additional.py create mode 100644 tests/featurization/test_texture.py create mode 100644 tests/featurization/test_volumesizeshape.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7b302e5..2673189 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -39,7 +39,7 @@ repos: - id: yamllint exclude: pre-commit-config.yaml - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.15.12" + rev: "v0.15.14" hooks: - id: ruff-format - id: ruff-check diff --git a/README.md b/README.md index c49e40c..624802e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ZedProfiler -[![Coverage](https://img.shields.io/badge/coverage-96%25-brightgreen)](#quality-gates) +[![Coverage](https://img.shields.io/badge/coverage-92%25-brightgreen)](#quality-gates) CPU-first 3D image feature extraction toolkit for high-content and high-throughput image-based profiling. diff --git a/ROADMAP.md b/ROADMAP.md index 2032efa..f6864d3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -46,8 +46,8 @@ The roadmap is intended to be a living document and may be updated as needed. 3. PR 3: RFC2119 naming specification and validators -- [ ] Port and adapt naming conventions into this repository. -- [ ] Add runtime and CI naming validation helpers and conformance tests. +- [x] Port and adapt naming conventions into this repository. +- [x] Add runtime and CI naming validation helpers and conformance tests. ### Phase 2: Feature modules and tests (PR 4-9) @@ -79,7 +79,7 @@ The roadmap is intended to be a living document and may be updated as needed. 10. PR 10: Integration matrix and parallelization guidance -- [ ] Cross-module integration tests and explicit non-goal docs for internal parallelization. +- [x] Cross-module integration tests and explicit non-goal docs for internal parallelization. 11. PR 11: Example notebooks and public dataset references @@ -100,8 +100,8 @@ The roadmap is intended to be a living document and may be updated as needed. ## Verification Gates -- [ ] Run full unit and integration tests on Linux with coverage >=85%. -- [ ] Run naming validation tests for all emitted feature names. +- [x] Run full unit and integration tests on Linux with coverage >=85%. +- [x] Run naming validation tests for all emitted feature names. - [ ] Build Sphinx docs in CI with warnings treated as errors. - [ ] Execute example notebooks in a clean environment. - [ ] Validate install/import from both wheel and sdist. diff --git a/justfile b/justfile index 7b735a4..45b0825 100644 --- a/justfile +++ b/justfile @@ -28,9 +28,12 @@ coverage-check: coverage lint: uv run ruff check . +lint-fix: + uv run ruff check . --fix + # Build Sphinx docs with docs dependencies. docs: cd docs && uv run --group docs sphinx-build src build # Run the full project workflow (env sync, lint, tests, coverage, and docs build). -all: sync lint test coverage-check docs +all: sync lint-fix lint test coverage-check docs diff --git a/src/zedprofiler/featurization/granularity.py b/src/zedprofiler/featurization/granularity.py index 7726a3c..31c9717 100644 --- a/src/zedprofiler/featurization/granularity.py +++ b/src/zedprofiler/featurization/granularity.py @@ -311,8 +311,6 @@ def compute_granularity( # noqa: C901, PLR0912, PLR0913, PLR0915 nobjects = len(label_range) if nobjects > 0: - label_range = numpy.arange(1, nobjects + 1) - # CellProfiler: self.labels[~im.mask] = 0 masked_labels = original_labels.copy() masked_labels[~original_mask] = 0 @@ -413,6 +411,7 @@ def compute_granularity( # noqa: C901, PLR0912, PLR0913, PLR0915 if non_zero > 0: vals = [v for v in object_measurements["value"] if v > 0] 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 diff --git a/tests/featurization/conftest.py b/tests/featurization/conftest.py new file mode 100644 index 0000000..0e7ec61 --- /dev/null +++ b/tests/featurization/conftest.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +import numpy as np +import pytest + + +@pytest.fixture +def rng() -> np.random.Generator: + return np.random.default_rng(0) diff --git a/tests/featurization/test_colocalization.py b/tests/featurization/test_colocalization.py new file mode 100644 index 0000000..6cb479d --- /dev/null +++ b/tests/featurization/test_colocalization.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest +from beartype import beartype +from pydantic import BaseModel, ConfigDict, field_validator + +skimage = pytest.importorskip("skimage") + +from zedprofiler.featurization.colocalization import ( # noqa: E402 + bisection_costes_threshold_calculation, + calculate_colocalization, + compute_colocalization, + linear_costes_threshold_calculation, + prepare_two_images_for_colocalization, +) + + +class ImageSetLoaderModel(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + image_set_name: str = "coloc" + + +class TwoObjectLoaderModel(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + image_set_loader: ImageSetLoaderModel + compartment: str + image1: np.ndarray + image2: np.ndarray + label_image: np.ndarray + object_ids: list[int] + + @field_validator("image1", "image2", "label_image", mode="before") + @classmethod + def to_array(_cls, v: object) -> np.ndarray: + return np.asarray(v) + + +@beartype +def make_pair( + shape: tuple[int, int, int], center: tuple[int, int, int] +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + label = np.zeros(shape, dtype=int) + z, y, x = center + label[z - 1 : z + 2, y - 1 : y + 2, x - 1 : x + 2] = 1 + im1 = np.zeros(shape, dtype=float) + im2 = np.zeros(shape, dtype=float) + # overlapping bright blob + im1[z, y, x] = 100.0 + im2[z, y, x] = 80.0 + return label, im1, im2 + + +@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] +) -> None: + imgset = ImageSetLoaderModel() + label, im1, im2 = make_pair(shape, center) + loader = TwoObjectLoaderModel( + image_set_loader=imgset, + compartment="Cell", + image1=im1, + image2=im2, + label_image=label, + object_ids=[1], + ) + + df = compute_colocalization(loader, channel1="A", channel2="B") + + assert isinstance(df, pd.DataFrame) + # Expect correlation column with morphology formatting present + assert any("Colocalization" in c for c in df.columns) + + +def test_linear_and_bisection_costes_thresholds_basic() -> None: + # simple linear relationship between channels + x = np.linspace(1.0, 100.0, 200) + img1 = x.reshape((200,)) + img2 = (2.0 * x + 5.0).reshape((200,)) + + thr_lin = linear_costes_threshold_calculation(img1, img2, scale_max=255) + thr_bis = bisection_costes_threshold_calculation(img1, img2, scale_max=255) + expected_threshold_count = 2 + + assert isinstance(thr_lin, tuple) and len(thr_lin) == expected_threshold_count + assert isinstance(thr_bis, tuple) and len(thr_bis) == expected_threshold_count + + for t in (*thr_lin, *thr_bis): + assert isinstance(t, float) + assert t >= 0.0 + + +def test_prepare_two_images_for_colocalization_crops() -> None: + # create two identical label images with one object each and match images + shape = (7, 7, 7) + label = np.zeros(shape, dtype=int) + # 3x3x3 cube in center + label[2:5, 2:5, 2:5] = 1 + + im1 = np.zeros(shape, dtype=float) + im2 = np.zeros(shape, dtype=float) + expected_peak_im1 = 10.0 + expected_peak_im2 = 5.0 + im1[3, 3, 3] = expected_peak_im1 + im2[3, 3, 3] = expected_peak_im2 + + cropped1, cropped2 = prepare_two_images_for_colocalization( + label, label, im1, im2, 1, 1 + ) + + assert isinstance(cropped1, np.ndarray) and isinstance(cropped2, np.ndarray) + # crops should be small but non-empty and include the bright voxel + assert cropped1.size > 0 and cropped2.size > 0 + assert cropped1.max() >= expected_peak_im1 + assert cropped2.max() >= expected_peak_im2 + + +def test_calculate_colocalization_identical_images() -> None: + # identical images should give high correlation and Manders near 1 + rng = np.random.default_rng(0) + img = rng.uniform(0, 255, size=(6, 6, 6)).astype(float) + + results = calculate_colocalization(img, img, thr=10, fast_costes="Accurate") + + # expected keys present and sensible numeric values + expected_keys = ( + "Correlation", + "MandersCoeffM1", + "MandersCoeffM2", + "OverlapCoeff", + ) + for k in expected_keys: + assert k in results + assert isinstance(results[k], float) + + # identical images -> correlation close to 1 + min_expected_correlation = 0.9 + assert results["Correlation"] > min_expected_correlation + # Manders should be non-negative + assert results["MandersCoeffM1"] >= 0.0 + assert results["MandersCoeffM2"] >= 0.0 diff --git a/tests/featurization/test_granularity.py b/tests/featurization/test_granularity.py new file mode 100644 index 0000000..b1ac407 --- /dev/null +++ b/tests/featurization/test_granularity.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +from typing import ClassVar + +import numpy as np +import pandas as pd +import pytest +from beartype import beartype +from pydantic import BaseModel, ConfigDict, field_validator + +from zedprofiler.featurization.granularity import ( + _subsample_3d, + _upsample_3d, + compute_granularity, +) + +scipy = pytest.importorskip("scipy") + + +class ImageSetLoaderModel(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + image_set_name: str = "gran" + + +class ObjectLoaderModel(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + image: np.ndarray + label_image: np.ndarray + object_ids: list[int] + image_set_loader: ImageSetLoaderModel + compartment: str = "Cell" + channel: str = "Ch1" + + @field_validator("image", "label_image", mode="before") + @classmethod + def ensure_array(_cls, v: object) -> np.ndarray: + return np.asarray(v) + + +@beartype +def make_image_and_label( + 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) + z, y, x = center + image[z - 1 : z + 2, y - 1 : y + 2, x - 1 : x + 2] = 10.0 + label[z - 1 : z + 2, y - 1 : y + 2, x - 1 : x + 2] = 1 + return image, 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] +) -> None: + img, lab = make_image_and_label(shape, center) + imgset = ImageSetLoaderModel() + loader = ObjectLoaderModel( + image=img, + label_image=lab, + object_ids=[1], + image_set_loader=imgset, + ) + + df = compute_granularity(loader, radius=1, granular_spectrum_length=4) + assert isinstance(df, (pd.DataFrame,)) + # Expect Metadata_Object_ObjectID column + assert "Metadata_Object_ObjectID" in df.columns + + +def test_subsample_and_upsample_roundtrip() -> None: + data = np.arange(27.0).reshape((3, 3, 3)) + # subsample by factor 0.5 -> larger grid coords division + subsampled = _subsample_3d(data, np.array([1.5, 1.5, 1.5]), 0.5, order=1) + assert subsampled.ndim == data.ndim + # upsample back to original shape + up = _upsample_3d(subsampled, subsampled.shape, data.shape) + assert up.shape == data.shape + # values won't be identical due to interpolation, but structure preserved + assert up.max() >= data.max() * 0.5 + + +def test_compute_granularity_subsample_size_ge_1_uses_copy() -> None: + # subsample_size >= 1 returns a copy path (no subsampling) + shape = (6, 6, 6) + img = np.zeros(shape, dtype=float) + lab = np.zeros(shape, dtype=int) + img[3, 3, 3] = 10.0 + lab[3, 3, 3] = 1 + + class Dummy: + image = img + label_image = lab + object_ids: ClassVar[list[int]] = [1] + image_set_loader = type("ISL", (), {"image_set_name": "s"})() + compartment = "Cell" + channel = "Ch1" + + df = compute_granularity( + Dummy(), + radius=1, + granular_spectrum_length=3, + subsample_size=1.0, + ) + assert isinstance(df, pd.DataFrame) + assert "Metadata_Object_ObjectID" in df.columns + + +def test_compute_granularity_with_image_sample_size_background_path() -> None: + # exercise branch where image_sample_size < 1 triggers background subsampling + shape = (12, 12, 12) + img = np.zeros(shape, dtype=float) + lab = np.zeros(shape, dtype=int) + img[6, 6, 6] = 20.0 + lab[6, 6, 6] = 1 + + class Dummy: + image = img + label_image = lab + object_ids: ClassVar[list[int]] = [1] + image_set_loader = type("ISL", (), {"image_set_name": "s"})() + compartment = "Cell" + channel = "Ch1" + + # small image_sample_size will go through background subsampling branch + df = compute_granularity( + Dummy(), + radius=1, + granular_spectrum_length=4, + subsample_size=0.5, + image_sample_size=0.5, + ) + assert isinstance(df, pd.DataFrame) + assert df.shape[0] >= 1 + + +def test_compute_granularity_mask_handling_and_zero_volume_skips() -> None: + # Provide a mask that excludes the object to trigger empty thresholds/path + shape = (8, 8, 8) + img = np.zeros(shape, dtype=float) + lab = np.zeros(shape, dtype=int) + img[4, 4, 4] = 50.0 + lab[4, 4, 4] = 1 + + mask = np.zeros(shape, dtype=bool) # exclude everything + + class Dummy: + image = img + label_image = lab + object_ids: ClassVar[list[int]] = [1] + image_set_loader = type("ISL", (), {"image_set_name": "s"})() + compartment = "Cell" + channel = "Ch1" + + # With mask excluding pixels, function should still run and return DataFrame + df = compute_granularity( + Dummy(), radius=1, granular_spectrum_length=3, image_mask=mask + ) + assert isinstance(df, pd.DataFrame) + + +def test_compute_granularity_preserves_sparse_label_ids() -> None: + # Sparse labels should not be renumbered to 1..n internally. + shape = (8, 8, 8) + img = np.zeros(shape, dtype=float) + lab = np.zeros(shape, dtype=int) + img[2, 2, 2] = 10.0 + img[5, 5, 5] = 20.0 + lab[2, 2, 2] = 257 + lab[5, 5, 5] = 514 + + class Dummy: + image = img + label_image = lab + object_ids: ClassVar[list[int]] = [257, 514] + image_set_loader = type("ISL", (), {"image_set_name": "s"})() + compartment = "Cell" + channel = "Ch1" + + df = compute_granularity(Dummy(), radius=1, granular_spectrum_length=2) + assert isinstance(df, pd.DataFrame) + assert sorted(df["Metadata_Object_ObjectID"].tolist()) == [257, 514] diff --git a/tests/featurization/test_intensity.py b/tests/featurization/test_intensity.py new file mode 100644 index 0000000..2f01168 --- /dev/null +++ b/tests/featurization/test_intensity.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest +from beartype import beartype +from pydantic import BaseModel, ConfigDict, field_validator + +from zedprofiler.featurization.intensity import compute_intensity + + +class ImageSetLoaderModel(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + image_set_name: str = "intensity" + + +class ObjectLoaderModel(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + image: np.ndarray + label_image: np.ndarray + object_ids: list[int] + image_set_loader: ImageSetLoaderModel + compartment: str = "Cell" + channel: str = "Ch1" + + @field_validator("image", "label_image", mode="before") + @classmethod + def to_array(_cls, v: object) -> np.ndarray: + return np.asarray(v) + + +@beartype +def make_label_and_image( + 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) + z, y, x = center + image[z, y, x] = 50.0 + label[z, y, x] = 1 + return image, label + + +@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] +) -> None: + img, lab = make_label_and_image(shape, center) + imgset = ImageSetLoaderModel() + loader = ObjectLoaderModel( + image=img, + label_image=lab, + object_ids=[1], + image_set_loader=imgset, + ) + + df = compute_intensity(loader) + assert isinstance(df, pd.DataFrame) + assert "Metadata_Object_ObjectID" in df.columns diff --git a/tests/featurization/test_neighbors.py b/tests/featurization/test_neighbors.py new file mode 100644 index 0000000..cb5b3f3 --- /dev/null +++ b/tests/featurization/test_neighbors.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest +from beartype import beartype +from pydantic import BaseModel, ConfigDict, field_validator + +from zedprofiler.featurization.neighbors import ( + calculate_centroid, + compute_neighbors, + create_results_dataframe, + crop_3D_image, + euclidean_distance_from_centroid, + get_coordinates, + mahalanobis_distance_from_centroid, + neighbors_expand_box, + plot_distance_distributions, + visualize_organoid_shells, +) + + +class ImageSetLoaderModel(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + image_set_name: str = "neighbors" + + +class ObjectLoaderModel(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + label_image: np.ndarray + object_ids: list[int] + image_set_loader: ImageSetLoaderModel + compartment: str = "Cell" + channel: str = "Ch1" + + @field_validator("label_image", mode="before") + @classmethod + def to_array(_cls, v: object) -> np.ndarray: + return np.asarray(v) + + +@beartype +def make_two_labels( + 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): + lab[z, y, x] = i + return lab + + +@pytest.mark.parametrize( + "shape,centers", + [ + ((10, 10, 10), [(3, 3, 3), (6, 6, 6)]), + ], +) +def test_compute_neighbors_counts( + 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 + ) + + df = compute_neighbors(loader, distance_threshold=5, anisotropy_factor=1) + assert isinstance(df, pd.DataFrame) + assert "Metadata_Object_ObjectID" in df.columns + + +def test_neighbors_expand_box_bounds() -> None: + # current_min - expand_by < min_coor -> clipped to min_coor + min_coord = 0 + max_coord = 10 + a, _b = neighbors_expand_box(min_coord, max_coord, 1, 2, expand_by=5) + assert a == 0 + # current_max + expand_by > max_coord -> clipped to max_coord + _a2, b2 = neighbors_expand_box(min_coord, max_coord, 8, 9, expand_by=5) + assert b2 == max_coord + + +def test_crop_3d_image_basic() -> None: + img = np.arange(27).reshape((3, 3, 3)) + cropped = crop_3D_image(img, (1, 0, 0, 3, 2, 2)) + assert cropped.shape == (2, 2, 2) + + +def test_get_coordinates_and_distances_and_centroid() -> None: + lab = np.zeros((5, 5, 5), dtype=int) + lab[1, 1, 1] = 1 + lab[3, 3, 3] = 2 + + coords = get_coordinates(lab, object_ids=[1, 2]) + assert list(coords["Metadata_Object_ObjectID"]) == [1, 2] + + centroid = calculate_centroid(coords[["x", "y", "z"]].to_numpy()) + assert centroid.shape == (3,) + + dists = euclidean_distance_from_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 + 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 + ) + 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 + 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 + ) + assert md_sing.shape[0] == repeated_count + # distances should be zeros because all identical + assert np.allclose(md_sing, 0.0) + + +def test_create_results_dataframe_and_errors_and_plots() -> None: + # create a simple classification results dict + results = { + "Metadata_Object_ObjectID": np.array([1, 2, 3]), + "ShellAssignments": np.array([0, 1, 1]), + "DistancesFromCenter": np.array([0.0, 1.0, 2.0]), + "DistancesFromExterior": np.array([2.0, 1.0, 0.0]), + "NormalizedDistancesFromCenter": np.array([0.0, 0.5, 1.0]), + "ShellsUsed": 2, + } + df = create_results_dataframe(results) + assert isinstance(df, pd.DataFrame) + + with pytest.raises(ValueError): + create_results_dataframe([]) + + 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]) + ) + assert hasattr(fig1, "axes") + + fig2 = plot_distance_distributions(results, n_shells=2) + assert hasattr(fig2, "axes") diff --git a/tests/featurization/test_neighbors_additional.py b/tests/featurization/test_neighbors_additional.py new file mode 100644 index 0000000..328d0e9 --- /dev/null +++ b/tests/featurization/test_neighbors_additional.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from zedprofiler.featurization.neighbors import ( + calculate_centroid, + classify_cells_into_shells, + compute_neighbors, + create_results_dataframe, + crop_3D_image, + euclidean_distance_from_centroid, + get_coordinates, + mahalanobis_distance_from_centroid, + neighbors_expand_box, + plot_distance_distributions, + visualize_organoid_shells, +) +from zedprofiler.IO.feature_writing_utils import format_morphology_feature_name + +scipy = pytest.importorskip("scipy") +skimage = pytest.importorskip("skimage") + + +def test_neighbors_expand_box_bounds() -> None: + # current_min - expand_by < min_coor -> clipped to min_coor + min_coord = 0 + max_coord = 10 + a, _b = neighbors_expand_box(min_coord, max_coord, 1, 2, expand_by=5) + assert a == 0 + # current_max + expand_by > max_coord -> clipped to max_coord + _a2, b2 = neighbors_expand_box(min_coord, max_coord, 8, 9, expand_by=5) + assert b2 == max_coord + + +def test_crop_3d_image_basic() -> None: + img = np.arange(27).reshape((3, 3, 3)) + cropped = crop_3D_image(img, (1, 0, 0, 3, 2, 2)) + assert cropped.shape == (2, 2, 2) + + +def test_compute_neighbors_distance_counts() -> None: + # Create a label image with three objects: two nearby, one far + lab = np.zeros((12, 12, 12), dtype=int) + lab[2, 2, 2] = 1 + lab[2, 2, 4] = 2 + lab[10, 10, 10] = 3 + + class Dummy: + label_image = lab + object_ids = (1, 2, 3) + image_set_loader = type("ISL", (), {"image_set_name": "s"})() + compartment = "Cell" + channel = "Ch1" + + df = compute_neighbors(Dummy(), distance_threshold=3, anisotropy_factor=1) + assert isinstance(df, pd.DataFrame) + # For object 1 and 2, distance-based neighbors should count each other + distance_threshold = 3 + col = format_morphology_feature_name( + compartment="Cell", + channel="Ch1", + feature_type="Neighbors", + measurement=f"NeighborsCountByDistance-{distance_threshold}", + ) + vals = df[col].tolist() + # find values for labels in order + assert vals[0] >= 1 + assert vals[1] >= 1 + # object 3 is far -> zero neighbors by distance + assert vals[2] == 0 + + +def test_get_coordinates_and_distances_and_centroid() -> None: + lab = np.zeros((5, 5, 5), dtype=int) + lab[1, 1, 1] = 1 + lab[3, 3, 3] = 2 + + coords = get_coordinates(lab, object_ids=[1, 2]) + assert list(coords["Metadata_Object_ObjectID"]) == [1, 2] + + centroid = calculate_centroid(coords[["x", "y", "z"]].to_numpy()) + assert centroid.shape == (3,) + + dists = euclidean_distance_from_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 + 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 + ) + ed_small = euclidean_distance_from_centroid(coords_small, centroid) + assert np.allclose(md_small, ed_small) + + # regularized branch with many identical points -> singular covariance + 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 + ) + assert md_sing.shape[0] == repeated_count + # distances should be zeros because all identical + assert np.allclose(md_sing, 0.0) + + +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"]) + ) + assert res["Metadata_Object_ObjectID"] == [] + assert cent is None + + # small set with requested large n_shells -> will reduce + 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 + ) + # max_shells = max(2, 6//3==2) -> will reduce to 2 + expected_shells = 2 + assert results["ShellsUsed"] == expected_shells + + +def test_create_results_dataframe_and_errors_and_plots() -> None: + # create a simple classification results dict + results = { + "Metadata_Object_ObjectID": np.array([1, 2, 3]), + "ShellAssignments": np.array([0, 1, 1]), + "DistancesFromCenter": np.array([0.0, 1.0, 2.0]), + "DistancesFromExterior": np.array([2.0, 1.0, 0.0]), + "NormalizedDistancesFromCenter": np.array([0.0, 0.5, 1.0]), + "ShellsUsed": 2, + } + df = create_results_dataframe(results) + assert isinstance(df, pd.DataFrame) + + with pytest.raises(ValueError): + create_results_dataframe([]) + + 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]) + ) + assert hasattr(fig1, "axes") + + fig2 = plot_distance_distributions(results, n_shells=2) + assert hasattr(fig2, "axes") diff --git a/tests/featurization/test_texture.py b/tests/featurization/test_texture.py new file mode 100644 index 0000000..aaee06a --- /dev/null +++ b/tests/featurization/test_texture.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest +from beartype import beartype +from pydantic import BaseModel, ConfigDict, field_validator + +mahotas = pytest.importorskip("mahotas") + +from zedprofiler.featurization.texture import compute_texture # noqa: E402 + + +class ImageSetLoaderModel(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + image_set_name: str = "texture" + + +class ObjectLoaderModel(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + image: np.ndarray + label_image: np.ndarray + object_ids: list[int] + image_set_loader: ImageSetLoaderModel + compartment: str = "Cell" + channel: str = "Ch1" + + @field_validator("image", "label_image", mode="before") + @classmethod + def to_array(_cls, v: object) -> np.ndarray: + return np.asarray(v) + + +@beartype +def make_texture_image( + 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) + z, y, x = center + image[z - 1 : z + 2, y - 1 : y + 2, x - 1 : x + 2] = 100 + label[z - 1 : z + 2, y - 1 : y + 2, x - 1 : x + 2] = 1 + return image, label + + +@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] +) -> None: + image, label = make_texture_image(shape, center) + imgset = ImageSetLoaderModel() + loader = ObjectLoaderModel( + image=image, label_image=label, object_ids=[1], image_set_loader=imgset + ) + + df = compute_texture(loader, distance=1, grayscale=256) + assert isinstance(df, pd.DataFrame) + assert "Metadata_Object_ObjectID" in df.columns diff --git a/tests/featurization/test_volumesizeshape.py b/tests/featurization/test_volumesizeshape.py new file mode 100644 index 0000000..d3c683f --- /dev/null +++ b/tests/featurization/test_volumesizeshape.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest +from beartype import beartype +from pydantic import BaseModel, ConfigDict, field_validator + +from zedprofiler.featurization.volumesizeshape import ( + compute_volume_size_shape, +) + + +class ImageSetLoaderModel(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + anisotropy_spacing: tuple[float, float, float] + image_set_name: str = "testset" + + +class ObjectLoaderModel(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + label_image: np.ndarray + object_ids: list[int] + image_set_loader: ImageSetLoaderModel + compartment: str = "Cell" + channel: str = "Ch1" + + @field_validator("label_image", mode="before") + @classmethod + def ensure_ndarray(_cls, v: object) -> np.ndarray: + return np.asarray(v) + + +@beartype +def make_label_image( + 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): + z = int(z0) + y = int(y0) + x = int(x0) + # small 3x3x3 cube + lab[max(0, z - 1) : z + 2, max(0, y - 1) : y + 2, max(0, x - 1) : x + 2] = i + return lab + + +@pytest.mark.parametrize( + "shape,centers", + [ + ((7, 7, 7), [(3, 3, 3)]), + ((8, 8, 8), [(2, 2, 2), (5, 5, 5)]), + ], +) +def test_compute_volume_size_shape_returns_dataframe( + 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) + obj_ids = sorted(set(label.ravel()) - {0}) + loader = ObjectLoaderModel( + label_image=label, + object_ids=obj_ids, + image_set_loader=imgset, + compartment="Nucleus", + channel="DAPI", + ) + + df = compute_volume_size_shape(image_set_loader=imgset, object_loader=loader) + + assert isinstance(df, pd.DataFrame) + assert "Metadata_Object_ObjectID" in df.columns + # All object ids present + returned_ids = sorted(int(x) for x in df["Metadata_Object_ObjectID"].tolist()) + assert returned_ids == obj_ids