From 4a8d65429ec311c8babd0c5121bc3e7d8dd32833 Mon Sep 17 00:00:00 2001 From: Soorya Pradeep Date: Thu, 23 Apr 2026 09:37:03 -0700 Subject: [PATCH 1/2] Add viscy-phenotyping package for image-based feature extraction Introduces a new sub-package with CLI commands for computing Spearman correlation features between DynaCLR PCs and computed image features: - compute-features: extracts per-cell morphology and texture features - write-header: initialises shared CSV before SLURM array jobs - list-fovs: enumerates FOVs in an OME-Zarr store - merge-features: concatenates per-FOV CSVs Nuclear label channel is now resolved by name from the tracking zarr (nuclear_label_channel in features.yml) rather than relying on np.squeeze over the label array. Co-Authored-By: Claude Sonnet 4.6 --- packages/viscy-phenotyping/DESIGN.md | 260 +++++++++++++++ packages/viscy-phenotyping/FEATURES.md | 250 +++++++++++++++ packages/viscy-phenotyping/README.md | 0 packages/viscy-phenotyping/pyproject.toml | 43 +++ .../src/viscy_phenotyping/__init__.py | 6 + .../src/viscy_phenotyping/cli.py | 300 ++++++++++++++++++ .../src/viscy_phenotyping/features.py | 86 +++++ .../src/viscy_phenotyping/features_density.py | 68 ++++ .../viscy_phenotyping/features_gradient.py | 64 ++++ .../src/viscy_phenotyping/features_radial.py | 166 ++++++++++ .../src/viscy_phenotyping/features_shape.py | 66 ++++ .../viscy_phenotyping/features_structure.py | 87 +++++ .../src/viscy_phenotyping/features_texture.py | 73 +++++ .../src/viscy_phenotyping/io.py | 29 ++ .../src/viscy_phenotyping/profiler.py | 67 ++++ .../viscy-phenotyping/tests/features_test.py | 77 +++++ .../viscy-phenotyping/tests/profiler_test.py | 264 +++++++++++++++ 17 files changed, 1906 insertions(+) create mode 100644 packages/viscy-phenotyping/DESIGN.md create mode 100644 packages/viscy-phenotyping/FEATURES.md create mode 100644 packages/viscy-phenotyping/README.md create mode 100644 packages/viscy-phenotyping/pyproject.toml create mode 100644 packages/viscy-phenotyping/src/viscy_phenotyping/__init__.py create mode 100644 packages/viscy-phenotyping/src/viscy_phenotyping/cli.py create mode 100644 packages/viscy-phenotyping/src/viscy_phenotyping/features.py create mode 100644 packages/viscy-phenotyping/src/viscy_phenotyping/features_density.py create mode 100644 packages/viscy-phenotyping/src/viscy_phenotyping/features_gradient.py create mode 100644 packages/viscy-phenotyping/src/viscy_phenotyping/features_radial.py create mode 100644 packages/viscy-phenotyping/src/viscy_phenotyping/features_shape.py create mode 100644 packages/viscy-phenotyping/src/viscy_phenotyping/features_structure.py create mode 100644 packages/viscy-phenotyping/src/viscy_phenotyping/features_texture.py create mode 100644 packages/viscy-phenotyping/src/viscy_phenotyping/io.py create mode 100644 packages/viscy-phenotyping/src/viscy_phenotyping/profiler.py create mode 100644 packages/viscy-phenotyping/tests/features_test.py create mode 100644 packages/viscy-phenotyping/tests/profiler_test.py diff --git a/packages/viscy-phenotyping/DESIGN.md b/packages/viscy-phenotyping/DESIGN.md new file mode 100644 index 000000000..2456fd8e0 --- /dev/null +++ b/packages/viscy-phenotyping/DESIGN.md @@ -0,0 +1,260 @@ +# viscy-phenotyping: Design Notes + +This document explains the architecture, design choices, and rationale behind +`viscy-phenotyping` as developed. It is intended as a reference for future +contributors and for understanding why things are the way they are. + +--- + +## Purpose + +`viscy-phenotyping` computes interpretable image-based features from fluorescence +microscopy patches and 2-D nuclear segmentation masks. It is designed to complement +DynaCLR embeddings — the features are structured so they can be joined with the +DynaCLR AnnData output on `(fov_name, track_id, t, id)` and correlated with the +learned embedding space. + +--- + +## Repository structure + +``` +packages/viscy-phenotyping/ +├── src/viscy_phenotyping/ +│ ├── __init__.py +│ ├── cli.py # Click CLI (entry points) +│ ├── profiler.py # Orchestrator: calls all feature modules per cell +│ ├── io.py # Border-safe 2-D patch cropping +│ ├── features.py # Nuclear morphology from full-FOV label images +│ ├── features_shape.py # Problem 6: nuclear shape / FSDs +│ ├── features_radial.py # Problems 1 & 3: radial distribution / ring uniformity +│ ├── features_texture.py # Problem 2: GLCM, LBP, intensity statistics +│ ├── features_density.py # Problem 4: Otsu spots, granularity spectrum +│ ├── features_structure.py # Problem 5: Canny edges, skeleton analysis +│ └── features_gradient.py # Problem 7: Sobel gradient, signal-to-background +├── tests/ +│ ├── features_test.py # Tests for extract_nuclear_morphology +│ └── profiler_test.py # Tests for all 7 feature modules + orchestrator +├── FEATURES.md # Feature reference documentation +├── DESIGN.md # This file +└── pyproject.toml +``` + +--- + +## The 7 phenotyping problems + +The feature set was designed around 7 biological questions: + +| # | Problem | Module | +|---|---|---| +| 1 | Radial distribution of signal from nuclear centre | `features_radial.py` | +| 2 | Signal homogeneity / texture | `features_texture.py` | +| 3 | Concentric ring / ER-like pattern uniformity | `features_radial.py` | +| 4 | Signal packing density and spot size | `features_density.py` | +| 5 | Edge count and strand/filament continuity | `features_structure.py` | +| 6 | Nuclear shape and circularity | `features_shape.py` | +| 7 | Gradient sharpness and nuclear-vs-cytoplasmic contrast | `features_gradient.py` | + +--- + +## Key design decision: mask usage + +### The problem + +An early version of the library applied the nuclear binary mask to restrict all feature +computation to pixels inside the nucleus. This is correct for nuclear features but wrong +for cytoplasmic channels — a channel showing cytoplasmic signal (e.g. ER, mitochondria) +would return near-zero or misleading values if only nuclear pixels were sampled. + +### The resolution + +The nuclear mask is used in three different ways depending on the feature: + +| Usage | Features | +|---|---| +| **Mask IS the object** — shape of the nucleus is what is being measured | `features_shape.py` | +| **Mask provides the centroid only** — radial profile is computed over all patch pixels | `features_radial.py` | +| **Mask separates nucleus from background** — used only for `nucleus_to_cytoplasm_ratio` (mean inside mask vs mean of all pixels outside mask) | `features_gradient.py` | +| **No mask** — features computed on the full fluorescence patch | `features_texture.py`, `features_density.py`, `features_structure.py` | + +This means the same library correctly handles both nuclear-localised and +cytoplasmic-localised channels without any reconfiguration. + +--- + +## Output format: CSV not AnnData + +The output is a plain CSV file with one row per cell per timepoint. Column layout: + +``` +fov_name, track_id, t, id, parent_track_id, parent_id, z, y, x, [feature columns...] +``` + +The index columns match `ULTRACK_INDEX_COLUMNS` from `viscy-data` exactly, so the CSV +can be joined directly with DynaCLR AnnData output using pandas: + +```python +import anndata as ad +import pandas as pd + +adata = ad.read_zarr("embeddings.zarr") +features = pd.read_csv("features.csv") +merged = adata.obs.merge(features, on=["fov_name", "track_id", "t", "id"]) +``` + +AnnData was considered for the output format but dropped because: +- It adds a heavy dependency to a pure CPU feature computation library +- Plain CSV is easier to inspect, share, and load in any tool +- The merge step is one line of pandas + +--- + +## CLI design + +Three commands are exposed via the `viscy-phenotyping` entry point: + +``` +viscy-phenotyping write-header # initialise output CSV with correct columns +viscy-phenotyping compute-features # process one or all FOVs +viscy-phenotyping list-fovs # enumerate FOV names from a zarr store +viscy-phenotyping merge-features # combine per-FOV CSVs (optional utility) +``` + +### `--fov-name` for parallelism + +`compute-features` accepts an optional `--fov-name` argument. Without it, all FOVs +are processed sequentially in one job. With it, only a single FOV is processed — +intended for SLURM array job parallelism where each task handles one FOV. + +### `write-header` + +Before any array jobs are submitted, `write-header` runs `compute_cell_features` on a +tiny synthetic patch (64×64, ones image, circular mask) to discover the full set of +output column names. It writes an empty CSV containing only the header row. + +This avoids a race condition in the parallel write step: if the shared output CSV did +not pre-exist, two array jobs finishing simultaneously could both try to write the +header, resulting in a duplicate header or corrupt file. + +--- + +## SLURM parallelisation + +### The pattern + +``` +submit_features.sh ← user runs this once + │ + ├── write-header ← creates shared CSV with header + ├── list-fovs ← writes fov_list.txt + └── sbatch --array=0-N ← one task per FOV + └── compute_features_worker.sh + ├── compute-features --fov-name FOV → /tmp/viscy_XXXX.csv + ├── flock -x → tail -n +2 /tmp/... >> shared.csv + └── rm /tmp/viscy_XXXX.csv +``` + +### Concurrent write safety + +Each worker: +1. Writes its output to a unique temp file in `/tmp` (local to the compute node, fast) +2. Acquires an exclusive `flock` lock on `{output}.lock` +3. Appends its rows (without header) to the shared CSV +4. Releases the lock and deletes the temp file + +This means the shared CSV grows incrementally as jobs complete — you can check +progress with `wc -l features.csv` while the array is running. + +### Why `/tmp` for the temp file + +Writing to `/tmp` (node-local storage) avoids contention on the shared HPC filesystem +(GPFS/Lustre) during the per-FOV computation. Only the final append touches the shared +filesystem under a lock, minimising I/O bottlenecks. + +### Config file + +All run parameters live in `features.yml`: + +```yaml +dataset_name: 2025_07_24_A549_SEC61_TOMM20_G3BP1_ZIKV +data_path: /hpc/.../registered.zarr +tracks_path: /hpc/.../tracks.zarr +output_csv: /hpc/.../dataset_name_features.csv +source_channels: + - "raw mCherry EX561 EM600-37" +nuclear_label_channel: "nuclei_prediction_labels_labels" +patch_size: [160, 160] +``` + +The submit script parses this with a Python heredoc and exports shell variables. +Channel names are joined with `|` before export (to survive shell word-splitting on +spaces) and rebuilt into a bash array in the worker script. + +--- + +## Label ID lookup: `track_id` not `id` + +The tracking zarr stores label images where each pixel value equals the `track_id` +of the cell occupying that pixel. The tracking CSV contains both `id` (a large unique +graph node identifier, e.g. `22000013`) and `track_id` (a small integer like `32`). + +The mask for a given cell is therefore: + +```python +mask = label_patch == int(row["track_id"]) # correct +mask = label_patch == int(row["id"]) # wrong — id never appears in the label image +``` + +This was discovered by inspecting the label array unique values and comparing them to +the CSV columns. + +--- + +## Label array shape handling + +Label arrays in this dataset have shape `(T, 1, 1, Y, X)` — multiple size-1 leading +dimensions from the OME-Zarr storage format. The CLI uses: + +```python +label_img = np.squeeze(np.asarray(label_array[t])) +``` + +`np.squeeze` removes all size-1 dimensions, resulting in `(Y, X)` regardless of how +many leading singleton dimensions the zarr contains. This is more robust than +`raw[0] if raw.ndim == 3 else raw`, which only handles one specific shape. + +--- + +## Correlation analysis + +`applications/dynaclr/pc_feature_correlation.py` correlates DynaCLR PCs with computed +features. + +### Usage + +```bash +uv run python applications/dynaclr/pc_feature_correlation.py \ + --embeddings /path/to/embeddings.zarr \ + --features /path/to/dataset_features.csv \ + --output /path/to/correlation_heatmap.svg \ + --n-pcs 8 \ + --top-n-features 5 # optional: top 5 features per PC (union across PCs) +``` + +Run from the root of the VisCy repository. + +### What it does + +1. Loads the DynaCLR AnnData zarr and runs PCA on the embeddings (`X` matrix) +2. **Filters to PCs with > 10% variance explained** — lower-variance PCs are dropped + from the plot. The variance % is shown in each y-axis label (e.g. `PC1 (32.1%)`) +3. Loads the features CSV +4. Joins on `(fov_name, track_id, t, id)` +5. Computes pairwise Spearman rank correlation between each PC and each feature column + directly — **not** via a full square `.corr()` matrix, so PC↔PC and feature↔feature + correlations are never computed or displayed +6. Saves a heatmap with PCs on the Y-axis and features on the X-axis + +Spearman rank correlation is used (rather than Pearson) because many image features +are non-normally distributed. diff --git a/packages/viscy-phenotyping/FEATURES.md b/packages/viscy-phenotyping/FEATURES.md new file mode 100644 index 000000000..54eb04ddb --- /dev/null +++ b/packages/viscy-phenotyping/FEATURES.md @@ -0,0 +1,250 @@ +# viscy-phenotyping: Feature Reference + +All features are computed per cell per timepoint. Per-channel features are prefixed +with the channel name (e.g. `raw_mCherry_EX561_EM600-37_intensity_cv`). Nuclear shape +features have no channel prefix and are computed once per cell. + +--- + +## Nuclear Shape Features (Problem 6) + +**Source:** `features_shape.py` — `shape_features(mask)` +**Input:** Binary nuclear mask (2-D, no intensity image required) +**Prefix:** none + +These features describe the geometry of the nucleus boundary. They are independent of +fluorescence signal and are the same regardless of which channel is being processed. + +| Feature | Description | +|---|---| +| `circularity` | 4π × area / perimeter². Equals 1.0 for a perfect circle. Low values indicate elongated or irregular shapes. | +| `convexity` | Convex-hull perimeter / object perimeter. Equals 1.0 for a fully convex shape. Values < 1 indicate lobes or indentations in the boundary. | +| `radial_std_norm` | Standard deviation of boundary radii (distance from centroid to each contour point), normalised by the mean radius. High values indicate an irregular or multi-lobed boundary. | +| `fsd_1` … `fsd_6` | Fourier Shape Descriptor amplitudes. The nuclear boundary is decomposed into Fourier harmonics; each `fsd_k` is the amplitude of the k-th harmonic normalised by the first harmonic. Low-order descriptors (fsd_1, fsd_2) capture global elongation and ellipticity; higher-order descriptors capture finer lobes and protrusions. | + +--- + +## Radial Distribution Features (Problem 1) + +**Source:** `features_radial.py` — `radial_distribution_features(image, nuclear_mask)` +**Input:** Full fluorescence patch; nuclear mask used only to locate the centroid +**Prefix:** `{channel}_` + +These features describe how fluorescence intensity is distributed radially outward from +the nuclear centre across the entire patch. + +| Feature | Description | +|---|---| +| `radial_frac_bin0` … `radial_frac_bin7` | Fraction of total patch intensity contained in each of 8 concentric rings, centred on the nuclear centroid. Bin 0 is the innermost ring (around the nucleus); bin 7 is the outermost (cytoplasm/background). | +| `radial_frac_cv` | Coefficient of variation (CV) across the 8 radial bins. High = signal concentrated in a few rings; low = evenly distributed. | +| `radial_slope` | Slope of a linear fit to mean bin intensity vs radial distance from the nuclear centroid, negated and normalised by mean intensity. Positive = signal is brighter at the centre of the patch (decreases outward); negative = signal is brighter at the periphery / boundary (increases outward). | +| `com_offset_norm` | Distance between the intensity centre-of-mass of the full patch and the geometric nuclear centroid, normalised by the equivalent circle radius of the nucleus. High = signal is shifted to one side of the nucleus. | +| `angular_cv` | CV of mean intensity across 8 angular sectors centred on the nucleus. High = signal is angularly asymmetric (concentrated in one direction). | + +--- + +## Concentric Ring Uniformity Features (Problem 3) + +**Source:** `features_radial.py` — `concentric_uniformity_features(image, nuclear_mask)` +**Input:** Full fluorescence patch; nuclear mask used only to locate the centroid +**Prefix:** `{channel}_` + +These features characterise the uniformity and periodicity of the radial intensity +profile — useful for detecting ER-like concentric ring patterns. + +| Feature | Description | +|---|---| +| `radial_profile_cv` | CV of the mean intensity across 16 concentric radial bins. Low = flat, uniform profile across the patch; high = one or more bright rings. | +| `radial_dominant_freq` | Index of the dominant frequency component in the FFT of the radial profile (1 = one bright ring, 2 = two rings, etc.). | +| `radial_spectral_cv` | CV of FFT amplitudes. Low = one clearly dominant frequency (regular ring spacing); high = irregular multi-frequency profile. | +| `radial_autocorr_lag1` | Lag-1 autocorrelation of the radial profile. High = slowly varying / smooth radial structure; near zero = no spatial correlation. | +| `peak_spacing_cv` | CV of distances between consecutive peaks in the radial profile. Low = evenly spaced rings; NaN if fewer than 2 peaks are found. | + +--- + +## Texture / Homogeneity Features (Problem 2) + +**Source:** `features_texture.py` — `texture_features(image)` +**Input:** Full fluorescence patch (no mask) +**Prefix:** `{channel}_` + +These features characterise the spatial heterogeneity and co-occurrence structure of +pixel intensities across the entire patch. + +### Intensity statistics + +| Feature | Description | +|---|---| +| `intensity_mean` | Mean intensity of all pixels in the patch. Reflects the overall brightness of the fluorescence signal. | +| `intensity_median` | Median intensity of all pixels in the patch. More robust than the mean to bright outliers (e.g. single saturated spots). | +| `intensity_cv` | Coefficient of variation (std / mean) of all pixel intensities. Low = uniform signal; high = highly variable. | +| `intensity_entropy` | Shannon entropy of the 64-bin intensity histogram. High = broad, spread-out intensity distribution; low = narrow/concentrated. | + +### GLCM Haralick features + +The Grey-Level Co-occurrence Matrix (GLCM) is computed at 2 distances (1 px, 3 px) +and 4 angles (0°, 45°, 90°, 135°). Each property is summarised as a mean and standard +deviation across all distance–angle combinations. + +| Feature | Description | +|---|---| +| `glcm_contrast_mean/std` | Measures local intensity variation. High = large differences between neighbouring pixels (sharp edges, heterogeneous texture). | +| `glcm_dissimilarity_mean/std` | Similar to contrast but grows linearly with difference rather than quadratically. | +| `glcm_homogeneity_mean/std` | Measures closeness of element distribution to the GLCM diagonal. High = similar neighbouring pixels (smooth, homogeneous signal). | +| `glcm_energy_mean/std` | Sum of squared GLCM elements (also called Angular Second Moment). High = repeating or uniform texture. | +| `glcm_correlation_mean/std` | Correlation between neighbouring pixel grey levels. High = linear relationships between neighbouring pixels. | +| `glcm_ASM_mean/std` | Angular Second Moment — same as energy, provides a redundant but commonly reported measure of texture uniformity. | + +### Local Binary Pattern features + +LBP encodes the local neighbourhood structure of each pixel as a binary code. + +| Feature | Description | +|---|---| +| `lbp_entropy` | Shannon entropy of the LBP histogram. High = diverse local texture patterns; low = repetitive or uniform texture. | +| `lbp_energy` | Sum of squared LBP histogram values. High = one or few dominant texture patterns. | + +--- + +## Signal Packing Density Features (Problem 4) + +**Source:** `features_density.py` — `density_features(image)` +**Input:** Full fluorescence patch (no mask) +**Prefix:** `{channel}_` + +These features characterise how densely bright structures (spots, puncta, organelles) +are packed across the patch. + +### Binary thresholding features + +An Otsu threshold is applied to segment bright structures from background. + +| Feature | Description | +|---|---| +| `binary_area_fraction` | Fraction of all patch pixels above the Otsu threshold. High = densely bright patch. | +| `spot_count` | Number of connected components in the thresholded binary image. | +| `spot_mean_area` | Mean area (pixels) of detected components. | +| `spot_max_area` | Area of the largest detected component. | +| `spot_density` | Spot count per patch pixel (spot_count / total pixels). | + +### Granularity spectrum + +Morphological opening removes structures smaller than the structuring element. The +fraction of signal removed at each scale quantifies the size distribution of bright +structures. + +| Feature | Description | +|---|---| +| `granularity_1` … `granularity_8` | Fraction of total image intensity removed by morphological opening with a disk of radius r (r = 1..8 pixels). High at small r = fine-grained puncta or dense small spots. High at large r = coarse or large bright regions. The peak of the granularity spectrum indicates the dominant size scale of bright structures. | + +--- + +## Edge Density and Strand Continuity Features (Problem 5) + +**Source:** `features_structure.py` — `structure_features(image)` +**Input:** Full fluorescence patch (no mask) +**Prefix:** `{channel}_` + +These features characterise filamentous or strand-like structures (e.g. ER tubules, +cytoskeletal fibres) using edge detection and skeletonisation. + +### Edge features + +| Feature | Description | +|---|---| +| `edge_density` | Fraction of patch pixels classified as edges by Canny edge detection. High = many edges / complex boundary structure. | + +### Connected component features + +| Feature | Description | +|---|---| +| `n_connected_components` | Number of connected components in the Otsu-thresholded binary image. High = many disconnected structures. | +| `cc_mean_area` | Mean area of connected components. | +| `cc_max_area` | Area of the largest connected component. | +| `signal_euler_number` | Euler number of the binary signal (number of objects minus number of holes). Negative values indicate structures with holes (ring-like morphology). | + +### Skeleton features + +The binary image is skeletonised (reduced to single-pixel-wide centrelines). + +| Feature | Description | +|---|---| +| `skeleton_length` | Total number of skeleton pixels. High = long or numerous filamentous structures. | +| `skeleton_branch_points` | Number of junction pixels (connected to > 2 neighbours). High = complex, networked topology. | +| `skeleton_endpoints` | Number of terminal pixels (connected to exactly 1 neighbour). High = many broken strand ends. | +| `skeleton_mean_segment_length` | Skeleton length divided by (branch_points + endpoints/2 + 1). A proxy for strand continuity — high = few breaks between junctions, long uninterrupted strands. | + +--- + +## Gradient and Sharpness Features (Problem 7) + +**Source:** `features_gradient.py` — `gradient_features(image, nuclear_mask)` +**Input:** Full fluorescence patch for gradient statistics; nuclear mask used only for `nucleus_to_cytoplasm_ratio` +**Prefix:** `{channel}_` + +These features characterise the sharpness and boundary definition of signals across +the patch, and contrast between the nuclear and cytoplasmic regions. + +| Feature | Description | +|---|---| +| `gradient_mean` | Mean Sobel gradient magnitude across all patch pixels. High = many strong edges / sharp signal transitions. | +| `gradient_std` | Standard deviation of Sobel gradient magnitude. High = spatially variable edge strength (some very sharp, others diffuse). | +| `gradient_p95` | 95th-percentile of Sobel gradient magnitude. Reflects the sharpest edges in the patch without being dominated by outliers. | +| `laplacian_variance` | Variance of the discrete Laplacian across the patch. Commonly used as a sharpness/focus metric — high = sharp, well-defined boundaries. | +| `gradient_entropy` | Shannon entropy of the gradient magnitude histogram. High = diverse range of edge strengths; low = uniformly weak or uniformly strong edges. | +| `nucleus_mean_intensity` | Mean intensity of pixels inside the nuclear mask. Directly reflects nuclear signal brightness independent of background — the most sensitive feature for detecting changes in nuclear fluorescence intensity over time. | +| `cytoplasm_mean_intensity` | Mean intensity of all pixels outside the nuclear mask. Reflects background / cytoplasmic signal level. | +| `nucleus_to_cytoplasm_ratio` | Mean intensity inside the nuclear mask divided by the mean intensity of all pixels outside the nuclear mask. High = bright nuclear signal against a dark background; values < 1 = cytoplasmic signal brighter than nuclear. | + +--- + +## Nuclear Morphology Features (standalone) + +**Source:** `features.py` — `extract_nuclear_morphology(label_image, label_ids)` +**Input:** Full-FOV integer label image (2-D or 3-D); used independently of the patch pipeline +**Prefix:** none (returned as a DataFrame, one row per nucleus) + +These are classical region-property measurements on the segmented nuclear mask, useful +for population-level morphological profiling. + +### 2-D properties + +| Feature | Description | +|---|---| +| `area` | Number of pixels in the nucleus. | +| `eccentricity` | Eccentricity of the best-fit ellipse (0 = circle, 1 = line). | +| `equivalent_diameter_area` | Diameter of a circle with the same area as the nucleus. | +| `extent` | Ratio of nucleus area to its bounding-box area. Low = irregular or non-compact shape. | +| `euler_number` | Number of objects minus number of holes. | +| `major_axis_length` | Length of the major axis of the best-fit ellipse. | +| `minor_axis_length` | Length of the minor axis of the best-fit ellipse. | +| `orientation` | Angle of the major axis relative to the horizontal (radians). | +| `perimeter` | Perimeter length of the nucleus boundary. | +| `solidity` | Nucleus area / convex-hull area. Low = concave or irregular shape. | +| `aspect_ratio` | major_axis_length / minor_axis_length. High = elongated nucleus. | + +### Additional 3-D properties + +| Feature | Description | +|---|---| +| `inertia_eigval_0/1/2` | Eigenvalues of the inertia tensor, describing the principal axes of mass distribution of the 3-D nucleus volume. | + +--- + +## Feature naming convention + +All per-channel features in the CSV output follow the pattern: + +``` +{channel_name}_{feature_name} +``` + +Spaces in channel names are replaced with underscores. For example, the `intensity_cv` +feature for channel `raw mCherry EX561 EM600-37` is stored as: + +``` +raw_mCherry_EX561_EM600-37_intensity_cv +``` + +Nuclear shape features (`circularity`, `convexity`, `radial_std_norm`, `fsd_1`…`fsd_6`) +have no channel prefix. diff --git a/packages/viscy-phenotyping/README.md b/packages/viscy-phenotyping/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/viscy-phenotyping/pyproject.toml b/packages/viscy-phenotyping/pyproject.toml new file mode 100644 index 000000000..9ef9b9734 --- /dev/null +++ b/packages/viscy-phenotyping/pyproject.toml @@ -0,0 +1,43 @@ +[build-system] +build-backend = "hatchling.build" +requires = [ "hatchling", "uv-dynamic-versioning" ] + +[project] +name = "viscy-phenotyping" +description = "Image-based phenotyping via nuclear morphology for VisCy" +readme = "README.md" +license = "BSD-3-Clause" +authors = [ { name = "Biohub", email = "compmicro@czbiohub.org" } ] +requires-python = ">=3.11" +dynamic = [ "version" ] +dependencies = [ + "click", + "iohub>=0.3a2", + "numpy>=2.4.1", + "pandas", + "scikit-image", + "scipy", + "viscy-data", +] + +[project.scripts] +viscy-phenotyping = "viscy_phenotyping.cli:main" + +[project.optional-dependencies] +analysis = [ "anndata", "scikit-learn", "seaborn" ] + +[dependency-groups] +dev = [ { include-group = "test" } ] +test = [ "pytest>=9.0.2", "pytest-cov>=7" ] + +[tool.hatch.version] +source = "uv-dynamic-versioning" + +[tool.hatch.build.targets.wheel] +packages = [ "src/viscy_phenotyping" ] + +[tool.uv-dynamic-versioning] +vcs = "git" +style = "pep440" +pattern-prefix = "viscy-phenotyping-" +fallback-version = "0.0.0" diff --git a/packages/viscy-phenotyping/src/viscy_phenotyping/__init__.py b/packages/viscy-phenotyping/src/viscy_phenotyping/__init__.py new file mode 100644 index 000000000..888598500 --- /dev/null +++ b/packages/viscy-phenotyping/src/viscy_phenotyping/__init__.py @@ -0,0 +1,6 @@ +"""Image-based phenotyping via nuclear morphology for VisCy.""" + +from viscy_phenotyping.features import extract_nuclear_morphology +from viscy_phenotyping.profiler import compute_cell_features + +__all__ = ["compute_cell_features", "extract_nuclear_morphology"] diff --git a/packages/viscy-phenotyping/src/viscy_phenotyping/cli.py b/packages/viscy-phenotyping/src/viscy_phenotyping/cli.py new file mode 100644 index 000000000..3b147102a --- /dev/null +++ b/packages/viscy-phenotyping/src/viscy_phenotyping/cli.py @@ -0,0 +1,300 @@ +"""CLI for viscy-phenotyping image-based feature extraction.""" + +import logging +from pathlib import Path + +import click +import numpy as np +import pandas as pd +from iohub.ngff import open_ome_zarr + +from viscy_data._typing import ULTRACK_INDEX_COLUMNS +from viscy_phenotyping.io import crop_2d +from viscy_phenotyping.profiler import compute_cell_features + +_logger = logging.getLogger(__name__) + +_INDEX_COLS = list(ULTRACK_INDEX_COLUMNS) + + +@click.group() +def main() -> None: + """viscy-phenotyping: image-based phenotyping tools.""" + + +def _get_fov_names(data_path: Path) -> list[str]: + """Return all FOV names from an OME-Zarr plate.""" + with open_ome_zarr(data_path, mode="r") as plate: + return [ + position.zgroup.name.strip("/") + for _, well in plate.wells() + for _, position in well.positions() + ] + + +def _process_fov( + fov_name: str, + data_path: Path, + tracks_path: Path, + source_channels: tuple[str, ...], + patch_size: tuple[int, int], + nuclear_label_channel: str, +) -> pd.DataFrame: + """Process a single FOV and return a DataFrame of features + obs columns.""" + csv_files = list((tracks_path / fov_name).glob("*.csv")) + if not csv_files: + _logger.warning("No tracking CSV found for %s — skipping.", fov_name) + return pd.DataFrame() + tracks_df = pd.read_csv(csv_files[0]) + + rows: list[dict] = [] + + with open_ome_zarr(data_path, mode="r") as data_plate, open_ome_zarr( + tracks_path, mode="r" + ) as tracks_plate: + position = data_plate[fov_name] + channel_names = list(position.channel_names) + try: + channel_indices = [channel_names.index(c) for c in source_channels] + except ValueError as exc: + raise click.ClickException(f"Channel not found in {fov_name}: {exc}") from exc + + tracks_position = tracks_plate[fov_name] + try: + label_channel_idx = list(tracks_position.channel_names).index(nuclear_label_channel) + except ValueError as exc: + raise click.ClickException( + f"Nuclear label channel '{nuclear_label_channel}' not found in {fov_name}: {exc}" + ) from exc + + img_array = position["0"] + label_array = tracks_position["0"] + + click.echo(f"Processing {fov_name} — {len(tracks_df)} cells") + + for t, t_group in tracks_df.groupby("t"): + t = int(t) + label_img = np.squeeze(np.asarray(label_array[t, label_channel_idx])) + img_frame = np.asarray(img_array[t, channel_indices]) # (C, [Z,] Y, X) + if img_frame.ndim == 4: + img_frame = img_frame.max(axis=1) # (C, Y, X) + + for _, row in t_group.iterrows(): + y, x = int(row["y"]), int(row["x"]) + cell_id = int(row["track_id"]) + + label_patch = crop_2d(label_img, y, x, patch_size) + img_patch = crop_2d(img_frame, y, x, patch_size) + + feat = compute_cell_features(img_patch, label_patch, cell_id, list(source_channels)) + if not feat: + _logger.warning( + "No features for cell id=%d at %s t=%d — skipping.", + cell_id, + fov_name, + t, + ) + continue + + obs = {"fov_name": fov_name} + obs.update({col: row.get(col) for col in _INDEX_COLS if col != "fov_name"}) + rows.append({**obs, **feat}) + + return pd.DataFrame(rows) + + +@main.command("write-header") +@click.option( + "--source-channel", + "source_channels", + multiple=True, + required=True, + help="Channel name(s) — must match what will be passed to compute-features.", +) +@click.option( + "--output", + required=True, + type=click.Path(path_type=Path), + help="CSV path to create with the header row.", +) +def write_header(source_channels: tuple[str, ...], output: Path) -> None: + """Write an empty CSV with the correct header for a given set of channels. + + Run this before submitting the SLURM array so the shared output CSV is + initialised with column names. Each array job then appends rows without + re-writing the header. + + Example + ------- + .. code-block:: bash + + viscy-phenotyping write-header \\ + --source-channel "raw mCherry EX561 EM600-37" \\ + --output features.csv + """ + # Run compute_cell_features on a minimal dummy patch to discover all column names + dummy_img = np.ones((len(source_channels), 64, 64), dtype=np.float32) + dummy_label = np.zeros((64, 64), dtype=np.int32) + dummy_label[20:44, 20:44] = 1 + feat = compute_cell_features(dummy_img, dummy_label, cell_id=1, channel_names=list(source_channels)) + columns = _INDEX_COLS + list(feat.keys()) + pd.DataFrame(columns=columns).to_csv(output, index=False) + click.echo(f"Wrote header with {len(columns)} columns to {output}") + + +@main.command("list-fovs") +@click.option( + "--data-path", + required=True, + type=click.Path(exists=True, path_type=Path), + help="OME-Zarr fluorescence image store.", +) +def list_fovs(data_path: Path) -> None: + """Print all FOV names in an OME-Zarr store, one per line.""" + for fov in _get_fov_names(data_path): + click.echo(fov) + + +@main.command("compute-features") +@click.option( + "--data-path", + required=True, + type=click.Path(exists=True, path_type=Path), + help="OME-Zarr fluorescence image store.", +) +@click.option( + "--tracks-path", + required=True, + type=click.Path(exists=True, path_type=Path), + help="OME-Zarr tracking store containing 2-D nuclear label images and per-FOV tracking CSVs.", +) +@click.option( + "--output", + required=True, + type=click.Path(path_type=Path), + help="Output CSV file path.", +) +@click.option( + "--source-channel", + "source_channels", + multiple=True, + required=True, + help="Channel name(s) to load from the fluorescence store. Repeat for multiple channels.", +) +@click.option( + "--patch-size", + nargs=2, + type=int, + default=(160, 160), + show_default=True, + help="YX patch size — must match the dynaCLR inference config.", +) +@click.option( + "--nuclear-label-channel", + required=True, + help="Channel name for 2-D nuclear label images inside each tracking-zarr position.", +) +@click.option( + "--fov-name", + default=None, + help="Process a single FOV only (for SLURM array jobs). If omitted, all FOVs are processed.", +) +@click.option( + "--overwrite", + is_flag=True, + default=False, + help="Overwrite an existing output CSV.", +) +def compute_features( + data_path: Path, + tracks_path: Path, + output: Path, + source_channels: tuple[str, ...], + patch_size: tuple[int, int], + nuclear_label_channel: str, + fov_name: str | None, + overwrite: bool, +) -> None: + """Compute image-based phenotyping features and write to a CSV file. + + For parallel execution across FOVs, use ``--fov-name`` with a SLURM array job + so each task writes its own CSV. Combine afterwards with ``merge-features``. + + Example (single run, all FOVs) + -------------------------------- + .. code-block:: bash + + viscy-phenotyping compute-features \\ + --data-path /data/registered.zarr \\ + --tracks-path /data/tracks.zarr \\ + --output /results/features.csv \\ + --source-channel "raw mCherry EX561 EM600-37" + + Example (per-FOV, for SLURM array) + ------------------------------------ + .. code-block:: bash + + viscy-phenotyping compute-features ... \\ + --fov-name A/1/000000 --output /results/fovs/A_1_000000.csv + """ + if output.exists() and not overwrite: + raise click.ClickException(f"{output} already exists. Use --overwrite to replace it.") + + fov_names = [fov_name] if fov_name is not None else _get_fov_names(data_path) + + dfs = [ + _process_fov(fov, data_path, tracks_path, source_channels, patch_size, nuclear_label_channel) + for fov in fov_names + ] + result = pd.concat([df for df in dfs if not df.empty], ignore_index=True) + + if result.empty: + raise click.ClickException("No features were extracted. Check that label IDs match the tracking zarr.") + + click.echo(f"Writing {len(result)} cells to {output}") + result.to_csv(output, index=False) + click.echo("Done.") + + +@main.command("merge-features") +@click.option( + "--input-dir", + required=True, + type=click.Path(exists=True, path_type=Path), + help="Directory containing per-FOV CSV files.", +) +@click.option( + "--output", + required=True, + type=click.Path(path_type=Path), + help="Output merged CSV path.", +) +@click.option( + "--overwrite", + is_flag=True, + default=False, + help="Overwrite an existing output CSV.", +) +def merge_features(input_dir: Path, output: Path, overwrite: bool) -> None: + """Concatenate per-FOV CSV files into a single merged CSV. + + Example + ------- + .. code-block:: bash + + viscy-phenotyping merge-features \\ + --input-dir /results/fovs/ \\ + --output /results/features.csv + """ + if output.exists() and not overwrite: + raise click.ClickException(f"{output} already exists. Use --overwrite to replace it.") + + csv_paths = sorted(input_dir.glob("*.csv")) + if not csv_paths: + raise click.ClickException(f"No CSV files found in {input_dir}") + + click.echo(f"Merging {len(csv_paths)} CSVs from {input_dir}") + merged = pd.concat((pd.read_csv(p) for p in csv_paths), ignore_index=True) + click.echo(f"Writing {len(merged)} total cells to {output}") + merged.to_csv(output, index=False) + click.echo("Done.") diff --git a/packages/viscy-phenotyping/src/viscy_phenotyping/features.py b/packages/viscy-phenotyping/src/viscy_phenotyping/features.py new file mode 100644 index 000000000..91dce778d --- /dev/null +++ b/packages/viscy-phenotyping/src/viscy_phenotyping/features.py @@ -0,0 +1,86 @@ +"""Nuclear morphology feature extraction from segmentation label images.""" + +import numpy as np +import pandas as pd +from skimage.measure import regionprops_table + +__all__ = [ + "NUCLEAR_MORPHOLOGY_PROPERTIES_2D", + "NUCLEAR_MORPHOLOGY_PROPERTIES_3D", + "extract_nuclear_morphology", +] + +# 2-D nuclear mask properties — includes eccentricity, perimeter, orientation. +NUCLEAR_MORPHOLOGY_PROPERTIES_2D = ( + "label", + "area", + "eccentricity", + "equivalent_diameter_area", + "extent", + "euler_number", + "major_axis_length", + "minor_axis_length", + "orientation", + "perimeter", + "solidity", +) + +# 3-D nuclear mask properties — eccentricity/perimeter/orientation are 2-D only. +NUCLEAR_MORPHOLOGY_PROPERTIES_3D = ( + "label", + "area", + "equivalent_diameter_area", + "extent", + "euler_number", + "major_axis_length", + "minor_axis_length", + "solidity", + "inertia_tensor_eigvals", +) + + +def extract_nuclear_morphology( + label_image: np.ndarray, + label_ids: np.ndarray, +) -> pd.DataFrame: + """Extract morphological features for requested label IDs from a label image. + + Parameters + ---------- + label_image : np.ndarray + Integer label image with shape ``(Y, X)`` or ``(Z, Y, X)``. + Background must be 0; each unique nonzero integer identifies one nucleus. + label_ids : np.ndarray + 1-D array of integer label IDs to measure. IDs absent from + ``label_image`` are silently dropped from the output. + + Returns + ------- + pd.DataFrame + One row per found label ID. Columns depend on dimensionality: + + *2-D*: ``label``, ``area``, ``eccentricity``, ``equivalent_diameter_area``, + ``extent``, ``euler_number``, ``major_axis_length``, ``minor_axis_length``, + ``orientation``, ``perimeter``, ``solidity``, ``aspect_ratio`` + + *3-D*: same minus 2-D-only columns, plus ``inertia_eigval_{0,1,2}``. + """ + properties = ( + NUCLEAR_MORPHOLOGY_PROPERTIES_2D if label_image.ndim == 2 else NUCLEAR_MORPHOLOGY_PROPERTIES_3D + ) + + masked = np.where(np.isin(label_image, label_ids), label_image, 0).astype(label_image.dtype) + props = regionprops_table(masked, properties=properties) + df = pd.DataFrame(props) + + if label_image.ndim == 3: + rename = { + col: f"inertia_eigval_{col.split('-')[-1]}" + for col in df.columns + if col.startswith("inertia_tensor_eigvals") + } + df = df.rename(columns=rename) + + df["aspect_ratio"] = df["major_axis_length"] / df["minor_axis_length"].clip(lower=1e-6) + + return df diff --git a/packages/viscy-phenotyping/src/viscy_phenotyping/features_density.py b/packages/viscy-phenotyping/src/viscy_phenotyping/features_density.py new file mode 100644 index 000000000..3209d1e9b --- /dev/null +++ b/packages/viscy-phenotyping/src/viscy_phenotyping/features_density.py @@ -0,0 +1,68 @@ +"""Signal packing density features.""" + +import numpy as np +from skimage.filters import threshold_otsu +from skimage.measure import label, regionprops +from skimage.morphology import disk, opening + +__all__ = ["density_features"] + +_N_GRANULARITY_SCALES = 8 + + +def density_features(image: np.ndarray) -> dict[str, float]: + """Problem 4: Signal packing density. + + Parameters + ---------- + image : np.ndarray, shape (Y, X) + Single-channel fluorescence patch. + + Returns + ------- + dict[str, float] + ``binary_area_fraction`` — fraction of patch pixels above Otsu threshold. + ``spot_count`` — number of connected components in thresholded signal. + ``spot_mean_area`` — mean area of those components. + ``spot_max_area`` — largest component area. + ``spot_density`` — spot count per patch pixel. + ``granularity_{r}`` — fraction of signal removed by morphological opening + with disk of radius r (r = 1..8); high value at small r = fine-grained/dense signal. + """ + out: dict[str, float] = {} + pixels = image.ravel() + + try: + thresh = threshold_otsu(pixels) + except ValueError: + thresh = pixels.mean() + binary = image > thresh + out["binary_area_fraction"] = float(binary.sum() / image.size) + + labeled = label(binary) + props = regionprops(labeled) + if props: + areas = np.array([p.area for p in props]) + out["spot_count"] = float(len(props)) + out["spot_mean_area"] = float(areas.mean()) + out["spot_max_area"] = float(areas.max()) + out["spot_density"] = float(len(props) / image.size) + else: + out.update(spot_count=0.0, spot_mean_area=0.0, spot_max_area=0.0, spot_density=0.0) + + # Granularity spectrum + lo, hi = pixels.min(), pixels.max() + img_uint8 = ( + ((image - lo) / (hi - lo + 1e-10) * 255).clip(0, 255).astype(np.uint8) + if hi > lo + else np.zeros_like(image, dtype=np.uint8) + ) + baseline = float(img_uint8.sum()) + 1e-10 + prev_sum = baseline + for r in range(1, _N_GRANULARITY_SCALES + 1): + opened = opening(img_uint8, disk(r)) + curr_sum = float(opened.sum()) + out[f"granularity_{r}"] = float((prev_sum - curr_sum) / baseline) + prev_sum = curr_sum + + return out diff --git a/packages/viscy-phenotyping/src/viscy_phenotyping/features_gradient.py b/packages/viscy-phenotyping/src/viscy_phenotyping/features_gradient.py new file mode 100644 index 000000000..3a2b68acd --- /dev/null +++ b/packages/viscy-phenotyping/src/viscy_phenotyping/features_gradient.py @@ -0,0 +1,64 @@ +"""Signal gradient and sharpness features.""" + +import numpy as np +from scipy.ndimage import laplace +from scipy.stats import entropy as scipy_entropy +from skimage.filters import sobel + +__all__ = ["gradient_features"] + + +def gradient_features(image: np.ndarray, nuclear_mask: np.ndarray) -> dict[str, float]: + """Problem 7: Gradient changes in the fluorescence signal. + + Parameters + ---------- + image : np.ndarray, shape (Y, X) + Single-channel fluorescence patch. + nuclear_mask : np.ndarray, shape (Y, X), bool or int + Binary nuclear mask — used only for ``nucleus_to_cytoplasm_ratio``. + + Returns + ------- + dict[str, float] + ``gradient_mean`` — mean Sobel gradient magnitude over the full patch. + ``gradient_std`` — std of gradient magnitude. + ``gradient_p95`` — 95th percentile of gradient magnitude (sharpest edges). + ``laplacian_variance`` — variance of the discrete Laplacian over the full patch + (high = sharp, well-defined signal boundaries). + ``gradient_entropy`` — Shannon entropy of the gradient magnitude histogram. + ``nucleus_mean_intensity`` — mean intensity of pixels inside the nuclear mask. + Directly reflects the brightness of nuclear signal independent of background. + ``cytoplasm_mean_intensity`` — mean intensity of all pixels outside the nuclear mask. + ``nucleus_to_cytoplasm_ratio`` — mean intensity inside the nuclear mask divided by the + mean intensity of all pixels outside the nuclear mask. High = bright nuclear signal + against a dark background; values < 1 = cytoplasmic signal brighter than nuclear. + """ + out: dict[str, float] = {} + + grad = sobel(image) + grad_flat = grad.ravel() + out["gradient_mean"] = float(grad_flat.mean()) + out["gradient_std"] = float(grad_flat.std()) + out["gradient_p95"] = float(np.percentile(grad_flat, 95)) + + out["laplacian_variance"] = float(laplace(image).var()) + + hist, _ = np.histogram(grad_flat, bins=64) + out["gradient_entropy"] = float(scipy_entropy(hist + 1e-10)) + + mask_bool = nuclear_mask.astype(bool) + nucleus_pixels = image[mask_bool] + if nucleus_pixels.size > 0: + background_pixels = image[~mask_bool] + mean_nucleus = float(nucleus_pixels.mean()) + mean_bg = float(background_pixels.mean()) if background_pixels.size > 0 else 1e-10 + out["nucleus_mean_intensity"] = mean_nucleus + out["cytoplasm_mean_intensity"] = mean_bg + out["nucleus_to_cytoplasm_ratio"] = float(mean_nucleus / (mean_bg + 1e-10)) + else: + out["nucleus_mean_intensity"] = 0.0 + out["cytoplasm_mean_intensity"] = float(image.mean()) + out["nucleus_to_cytoplasm_ratio"] = 1.0 + + return out diff --git a/packages/viscy-phenotyping/src/viscy_phenotyping/features_radial.py b/packages/viscy-phenotyping/src/viscy_phenotyping/features_radial.py new file mode 100644 index 000000000..3de516e07 --- /dev/null +++ b/packages/viscy-phenotyping/src/viscy_phenotyping/features_radial.py @@ -0,0 +1,166 @@ +"""Radial distribution and concentric ring uniformity features.""" + +import numpy as np +from scipy.ndimage import center_of_mass +from scipy.signal import find_peaks + +__all__ = ["radial_distribution_features", "concentric_uniformity_features"] + +_N_RADIAL_BINS = 8 +_N_SECTORS = 8 + + +def _radial_profile( + image: np.ndarray, cy: float, cx: float, n_bins: int +) -> tuple[np.ndarray, np.ndarray]: + """Mean intensity and fraction of total intensity per concentric ring. + + Uses all pixels in the patch, centred on (cy, cx). + """ + h, w = image.shape + ys, xs = np.mgrid[:h, :w] + ys_flat = ys.ravel() + xs_flat = xs.ravel() + pixels_flat = image.ravel() + radii = np.hypot(ys_flat - cy, xs_flat - cx) + edges = np.linspace(0, radii.max() + 1e-6, n_bins + 1) + total = pixels_flat.sum() + 1e-10 + bin_means = np.zeros(n_bins) + bin_fracs = np.zeros(n_bins) + for i in range(n_bins): + idx = (radii >= edges[i]) & (radii < edges[i + 1]) + ring_pixels = pixels_flat[idx] + if ring_pixels.size > 0: + bin_means[i] = ring_pixels.mean() + bin_fracs[i] = ring_pixels.sum() / total + return bin_means, bin_fracs + + +def radial_distribution_features( + image: np.ndarray, nuclear_mask: np.ndarray, n_bins: int = _N_RADIAL_BINS +) -> dict[str, float]: + """Problem 1: Radial distribution of fluorescence signal. + + Parameters + ---------- + image : np.ndarray, shape (Y, X) + Single-channel fluorescence patch. + nuclear_mask : np.ndarray, shape (Y, X), bool or int + Binary nuclear mask — used only to locate the nuclear centroid. + n_bins : int + Number of concentric radial bins. + + Returns + ------- + dict[str, float] + ``radial_frac_bin{i}`` — fraction of total intensity in each ring. + ``radial_frac_cv`` — CV across bins (high = signal concentrated in few rings). + ``radial_slope`` — slope of a linear fit to mean intensity vs radius, negated so + that positive values indicate centre-bright signal (intensity decreases outward) + and negative values indicate edge-bright / boundary signal (intensity increases + outward). Normalised by the mean intensity so it is scale-invariant. + ``com_offset_norm`` — intensity centre-of-mass offset from nuclear centroid, + normalised by equivalent circle radius (high = signal on one side). + ``angular_cv`` — CV of mean intensity across 8 angular sectors + (high = signal concentrated in one angular direction). + """ + mask_bool = nuclear_mask.astype(bool) + cy, cx = center_of_mass(mask_bool) + + bin_means, bin_fracs = _radial_profile(image, cy, cx, n_bins) + + out: dict[str, float] = {} + for i in range(n_bins): + out[f"radial_frac_bin{i}"] = float(bin_fracs[i]) + out["radial_frac_cv"] = float(bin_fracs.std() / (bin_fracs.mean() + 1e-10)) + + # Slope of linear fit to mean intensity vs bin index; negated so positive = centre-bright + bin_indices = np.arange(n_bins, dtype=float) + mean_intensity = bin_means.mean() + 1e-10 + slope = float(np.polyfit(bin_indices, bin_means, 1)[0]) + out["radial_slope"] = float(-slope / mean_intensity) + + # Intensity CoM (full patch) offset from nuclear centroid + h, w = image.shape + ys_g, xs_g = np.mgrid[:h, :w] + total = image.sum() + 1e-10 + iy = float((image * ys_g).sum() / total) + ix = float((image * xs_g).sum() / total) + com_offset = np.hypot(iy - cy, ix - cx) + eq_radius = np.sqrt(mask_bool.sum() / np.pi) + 1e-10 + out["com_offset_norm"] = float(com_offset / eq_radius) + + # Angular asymmetry across _N_SECTORS sectors (all patch pixels) + ys_flat = ys_g.ravel() + xs_flat = xs_g.ravel() + pixels_flat = image.ravel() + angles = np.arctan2(ys_flat - cy, xs_flat - cx) + edges_a = np.linspace(-np.pi, np.pi, _N_SECTORS + 1) + sector_means = np.zeros(_N_SECTORS) + for s in range(_N_SECTORS): + idx = (angles >= edges_a[s]) & (angles < edges_a[s + 1]) + sector_pixels = pixels_flat[idx] + sector_means[s] = sector_pixels.mean() if sector_pixels.size > 0 else 0.0 + out["angular_cv"] = float(sector_means.std() / (sector_means.mean() + 1e-10)) + + return out + + +def concentric_uniformity_features( + image: np.ndarray, nuclear_mask: np.ndarray, n_bins: int = 16 +) -> dict[str, float]: + """Problem 3: Uniformity of concentric ring pattern (ER-like structures). + + Parameters + ---------- + image : np.ndarray, shape (Y, X) + Single-channel fluorescence patch. + nuclear_mask : np.ndarray, shape (Y, X), bool or int + Binary nuclear mask — used only to locate the nuclear centroid. + n_bins : int + Number of radial bins for the profile (higher = finer resolution). + + Returns + ------- + dict[str, float] + ``radial_profile_cv`` — CV of the radial intensity profile + (low = uniform ring brightness). + ``radial_dominant_freq`` — index of dominant FFT frequency in the profile + (1 = one bright ring, 2 = two rings, etc.). + ``radial_spectral_cv`` — CV of FFT amplitudes (low = one dominant frequency). + ``radial_autocorr_lag1`` — lag-1 autocorrelation of profile + (high = slowly varying / smooth rings). + ``peak_spacing_cv`` — CV of distances between intensity peaks in the profile + (low = evenly spaced rings). NaN if fewer than 2 peaks found. + """ + mask_bool = nuclear_mask.astype(bool) + cy, cx = center_of_mass(mask_bool) + bin_means, _ = _radial_profile(image, cy, cx, n_bins) + out: dict[str, float] = {} + + out["radial_profile_cv"] = float(bin_means.std() / (bin_means.mean() + 1e-10)) + + profile = bin_means - bin_means.mean() + fft_amps = np.abs(np.fft.rfft(profile))[1:] # skip DC + if fft_amps.sum() > 1e-10: + out["radial_dominant_freq"] = float(np.argmax(fft_amps) + 1) + out["radial_spectral_cv"] = float(fft_amps.std() / (fft_amps.mean() + 1e-10)) + else: + out["radial_dominant_freq"] = 0.0 + out["radial_spectral_cv"] = 0.0 + + if len(bin_means) > 2 and bin_means.std() > 1e-10: + out["radial_autocorr_lag1"] = float( + np.corrcoef(bin_means[:-1], bin_means[1:])[0, 1] + ) + else: + out["radial_autocorr_lag1"] = 0.0 + + peaks, _ = find_peaks(bin_means) + if len(peaks) >= 2: + spacings = np.diff(peaks).astype(float) + out["peak_spacing_cv"] = float(spacings.std() / (spacings.mean() + 1e-10)) + else: + out["peak_spacing_cv"] = float("nan") + + return out diff --git a/packages/viscy-phenotyping/src/viscy_phenotyping/features_shape.py b/packages/viscy-phenotyping/src/viscy_phenotyping/features_shape.py new file mode 100644 index 000000000..058beb145 --- /dev/null +++ b/packages/viscy-phenotyping/src/viscy_phenotyping/features_shape.py @@ -0,0 +1,66 @@ +"""Nuclear shape and circularity features.""" + +import numpy as np +from skimage.measure import find_contours, regionprops +from skimage.morphology import convex_hull_image + +__all__ = ["shape_features"] + +_N_FOURIER_DESCRIPTORS = 6 + + +def shape_features(mask: np.ndarray) -> dict[str, float]: + """Problem 6: Circularity of the nuclear shape. + + Operates on the binary nuclear mask only (no intensity image needed). + Call once per cell without a channel prefix. + + Parameters + ---------- + mask : np.ndarray, shape (Y, X), bool or int + Binary nuclear mask. + + Returns + ------- + dict[str, float] + ``circularity`` — 4π·area/perimeter² (1.0 = perfect circle). + ``convexity`` — convex-hull perimeter / object perimeter + (1.0 = fully convex; < 1 = lobes / indentations). + ``radial_std_norm`` — std of boundary radii normalised by mean radius + (high = irregular / multilobed boundary). + ``fsd_{1..6}`` — Fourier shape descriptor amplitudes normalised by the + first harmonic (low-order = global shape; high-order = fine lobes). + """ + out: dict[str, float] = {} + mask_bool = mask.astype(bool) + if not mask_bool.any(): + return out + + props = regionprops(mask_bool.astype(np.uint8))[0] + area = float(props.area) + perimeter = float(props.perimeter) + cy, cx = props.centroid + + out["circularity"] = float(4.0 * np.pi * area / (perimeter**2 + 1e-10)) + + hull = convex_hull_image(mask_bool) + hull_perimeter = float(regionprops(hull.astype(np.uint8))[0].perimeter) + out["convexity"] = float(hull_perimeter / (perimeter + 1e-10)) + + contours = find_contours(mask_bool.astype(float), 0.5) + if contours: + contour = max(contours, key=len) + radii = np.hypot(contour[:, 0] - cy, contour[:, 1] - cx) + out["radial_std_norm"] = float(radii.std() / (radii.mean() + 1e-10)) + + boundary = (contour[:, 1] - cx) + 1j * (contour[:, 0] - cy) + fsd = np.abs(np.fft.fft(boundary)) + norm = fsd[1] + 1e-10 + for k in range(1, _N_FOURIER_DESCRIPTORS + 1): + out[f"fsd_{k}"] = float(fsd[k] / norm if k < len(fsd) else 0.0) + else: + out["radial_std_norm"] = 0.0 + for k in range(1, _N_FOURIER_DESCRIPTORS + 1): + out[f"fsd_{k}"] = 0.0 + + return out diff --git a/packages/viscy-phenotyping/src/viscy_phenotyping/features_structure.py b/packages/viscy-phenotyping/src/viscy_phenotyping/features_structure.py new file mode 100644 index 000000000..2ed3f7e88 --- /dev/null +++ b/packages/viscy-phenotyping/src/viscy_phenotyping/features_structure.py @@ -0,0 +1,87 @@ +"""Edge density and strand / filament continuity features.""" + +import numpy as np +from scipy.ndimage import convolve +from skimage.feature import canny +from skimage.filters import threshold_otsu +from skimage.measure import euler_number, label, regionprops +from skimage.morphology import skeletonize + +__all__ = ["structure_features"] + +_NEIGHBOR_KERNEL = np.ones((3, 3), dtype=np.int32) + + +def structure_features( + image: np.ndarray, canny_sigma: float = 1.0 +) -> dict[str, float]: + """Problem 5: Edge count and strand/filament continuity. + + Parameters + ---------- + image : np.ndarray, shape (Y, X) + Single-channel fluorescence patch. + canny_sigma : float + Gaussian smoothing sigma for Canny edge detection. + + Returns + ------- + dict[str, float] + ``edge_density`` — fraction of patch pixels classified as edges by Canny. + ``n_connected_components`` — number of connected components in thresholded signal. + ``cc_mean_area``, ``cc_max_area`` — mean / max component area. + ``signal_euler_number`` — Euler number of binary signal (objects minus holes). + ``skeleton_length`` — total number of skeleton pixels. + ``skeleton_branch_points`` — junction pixels in the skeleton (high = complex network). + ``skeleton_endpoints`` — terminal pixels (high = many broken strand ends). + ``skeleton_mean_segment_length`` — proxy for strand continuity + (high = few breaks / long strands). + """ + out: dict[str, float] = {} + pixels = image.ravel() + + edges = canny(image, sigma=canny_sigma) + out["edge_density"] = float(edges.sum() / image.size) + + try: + thresh = threshold_otsu(pixels) + except ValueError: + thresh = pixels.mean() + binary = image > thresh + + labeled = label(binary) + props = regionprops(labeled) + out["n_connected_components"] = float(len(props)) + if props: + areas = np.array([p.area for p in props]) + out["cc_mean_area"] = float(areas.mean()) + out["cc_max_area"] = float(areas.max()) + else: + out.update(cc_mean_area=0.0, cc_max_area=0.0) + + out["signal_euler_number"] = float(euler_number(binary)) + + if binary.any(): + skel = skeletonize(binary) + skel_length = int(skel.sum()) + neighbor_count = ( + convolve(skel.astype(np.int32), _NEIGHBOR_KERNEL, mode="constant") + - skel.astype(np.int32) + ) + branch_pts = int((skel & (neighbor_count > 2)).sum()) + endpoints = int((skel & (neighbor_count == 1)).sum()) + out["skeleton_length"] = float(skel_length) + out["skeleton_branch_points"] = float(branch_pts) + out["skeleton_endpoints"] = float(endpoints) + out["skeleton_mean_segment_length"] = float( + skel_length / (branch_pts + endpoints / 2.0 + 1.0) + ) + else: + out.update( + skeleton_length=0.0, + skeleton_branch_points=0.0, + skeleton_endpoints=0.0, + skeleton_mean_segment_length=0.0, + ) + + return out diff --git a/packages/viscy-phenotyping/src/viscy_phenotyping/features_texture.py b/packages/viscy-phenotyping/src/viscy_phenotyping/features_texture.py new file mode 100644 index 000000000..43edc361c --- /dev/null +++ b/packages/viscy-phenotyping/src/viscy_phenotyping/features_texture.py @@ -0,0 +1,73 @@ +"""Signal homogeneity and texture features.""" + +import numpy as np +from scipy.stats import entropy as scipy_entropy +from skimage.feature import graycomatrix, graycoprops, local_binary_pattern + +__all__ = ["texture_features"] + +_GLCM_DISTANCES = [1, 3] +_GLCM_ANGLES = [0, np.pi / 4, np.pi / 2, 3 * np.pi / 4] +_GLCM_LEVELS = 64 +_LBP_RADIUS = 2 +_LBP_POINTS = 8 * _LBP_RADIUS + + +def _scale_to_uint(image: np.ndarray, levels: int) -> np.ndarray: + """Scale image to [0, levels-1] using the full image range.""" + lo, hi = image.min(), image.max() + if hi <= lo: + return np.zeros_like(image, dtype=np.uint8) + return ((image - lo) / (hi - lo) * (levels - 1)).clip(0, levels - 1).astype(np.uint8) + + +def texture_features(image: np.ndarray) -> dict[str, float]: + """Problem 2: Homogeneity of fluorescence signal. + + Parameters + ---------- + image : np.ndarray, shape (Y, X) + Single-channel fluorescence patch. + + Returns + ------- + dict[str, float] + ``intensity_mean`` — mean intensity of all patch pixels. + ``intensity_median`` — median intensity of all patch pixels. + ``intensity_cv`` — coefficient of variation of all patch pixels. + ``intensity_entropy`` — Shannon entropy of the intensity histogram. + ``glcm_{prop}_mean/std`` — Haralick GLCM features averaged/spread over + distances and angles: contrast, dissimilarity, homogeneity, energy, + correlation, ASM. + ``lbp_entropy`` — entropy of the Local Binary Pattern histogram. + ``lbp_energy`` — energy of the LBP histogram. + """ + out: dict[str, float] = {} + pixels = image.ravel() + + out["intensity_mean"] = float(pixels.mean()) + out["intensity_median"] = float(np.median(pixels)) + out["intensity_cv"] = float(pixels.std() / (pixels.mean() + 1e-10)) + hist, _ = np.histogram(pixels, bins=64) + out["intensity_entropy"] = float(scipy_entropy(hist + 1e-10)) + + scaled = _scale_to_uint(image, _GLCM_LEVELS) + glcm = graycomatrix( + scaled, + distances=_GLCM_DISTANCES, + angles=_GLCM_ANGLES, + levels=_GLCM_LEVELS, + symmetric=True, + normed=True, + ) + for prop in ("contrast", "dissimilarity", "homogeneity", "energy", "correlation", "ASM"): + vals = graycoprops(glcm, prop).ravel() + out[f"glcm_{prop}_mean"] = float(vals.mean()) + out[f"glcm_{prop}_std"] = float(vals.std()) + + lbp = local_binary_pattern(_scale_to_uint(image, 256), _LBP_POINTS, _LBP_RADIUS, method="uniform") + lbp_hist, _ = np.histogram(lbp.ravel(), bins=_LBP_POINTS + 2, density=True) + out["lbp_entropy"] = float(scipy_entropy(lbp_hist + 1e-10)) + out["lbp_energy"] = float((lbp_hist**2).sum()) + + return out diff --git a/packages/viscy-phenotyping/src/viscy_phenotyping/io.py b/packages/viscy-phenotyping/src/viscy_phenotyping/io.py new file mode 100644 index 000000000..0bfc14026 --- /dev/null +++ b/packages/viscy-phenotyping/src/viscy_phenotyping/io.py @@ -0,0 +1,29 @@ +"""Data loading utilities for viscy-phenotyping.""" + +import numpy as np + +__all__ = ["crop_2d"] + + +def crop_2d(array: np.ndarray, y: int, x: int, patch_yx: tuple[int, int]) -> np.ndarray: + """Border-safe center crop along the last two axes of ``array``. + + Parameters + ---------- + array : np.ndarray + Array with shape ``(..., Y, X)``. + y, x : int + Requested crop center (cell centroid). + patch_yx : tuple[int, int] + Output patch height and width. + + Returns + ------- + np.ndarray + Cropped array with shape ``(..., patch_yx[0], patch_yx[1])``. + """ + H, W = array.shape[-2], array.shape[-1] + yh, xh = patch_yx[0] // 2, patch_yx[1] // 2 + yc = min(max(y, yh), H - yh) + xc = min(max(x, xh), W - xh) + return array[..., yc - yh : yc + yh, xc - xh : xc + xh] diff --git a/packages/viscy-phenotyping/src/viscy_phenotyping/profiler.py b/packages/viscy-phenotyping/src/viscy_phenotyping/profiler.py new file mode 100644 index 000000000..77d31b8b8 --- /dev/null +++ b/packages/viscy-phenotyping/src/viscy_phenotyping/profiler.py @@ -0,0 +1,67 @@ +"""Single-cell feature orchestrator calling all feature modules.""" + +import numpy as np + +from viscy_phenotyping.features_density import density_features +from viscy_phenotyping.features_gradient import gradient_features +from viscy_phenotyping.features_radial import concentric_uniformity_features, radial_distribution_features +from viscy_phenotyping.features_shape import shape_features +from viscy_phenotyping.features_structure import structure_features +from viscy_phenotyping.features_texture import texture_features + +__all__ = ["compute_cell_features"] + + +def compute_cell_features( + img_patch: np.ndarray, + label_patch: np.ndarray, + cell_id: int, + channel_names: list[str], +) -> dict[str, float]: + """Compute all image-based phenotyping features for a single cell. + + Parameters + ---------- + img_patch : np.ndarray, shape (C, Y, X) + Multi-channel fluorescence patch (already cropped to patch_size). + label_patch : np.ndarray, shape (Y, X) + Integer nuclear label patch. ``cell_id`` selects this cell's mask. + cell_id : int + Label ID of the target cell in ``label_patch``. + channel_names : list[str] + Names of channels in ``img_patch`` (used as feature prefixes). + + Returns + ------- + dict[str, float] + Flat dict of all features. Per-channel features are prefixed + ``{channel_name}_`` (spaces replaced with underscores). + Nuclear shape features (Problem 6) have no prefix. + """ + mask = label_patch == cell_id + if not mask.any(): + return {} + + features: dict[str, float] = {} + + # Problem 6: nuclear shape — mask only, no channel prefix + features.update(shape_features(mask)) + + for ch_idx, ch_name in enumerate(channel_names): + ch_img = img_patch[ch_idx].astype(np.float32) + prefix = ch_name.replace(" ", "_") + "_" + + # Problem 1: radial distribution (nuclear centroid from mask; profile over full patch) + features.update({prefix + k: v for k, v in radial_distribution_features(ch_img, mask).items()}) + # Problem 3: concentric ring uniformity (nuclear centroid from mask; profile over full patch) + features.update({prefix + k: v for k, v in concentric_uniformity_features(ch_img, mask).items()}) + # Problem 2: texture / homogeneity (full patch) + features.update({prefix + k: v for k, v in texture_features(ch_img).items()}) + # Problem 4: packing density (full patch) + features.update({prefix + k: v for k, v in density_features(ch_img).items()}) + # Problem 5: edge count / strand continuity (full patch) + features.update({prefix + k: v for k, v in structure_features(ch_img).items()}) + # Problem 7: gradient changes (full patch; mask used only for signal_to_background) + features.update({prefix + k: v for k, v in gradient_features(ch_img, mask).items()}) + + return features diff --git a/packages/viscy-phenotyping/tests/features_test.py b/packages/viscy-phenotyping/tests/features_test.py new file mode 100644 index 000000000..3a16a4586 --- /dev/null +++ b/packages/viscy-phenotyping/tests/features_test.py @@ -0,0 +1,77 @@ +"""Tests for nuclear morphology feature extraction.""" + +import numpy as np +import pytest + +from viscy_phenotyping.features import extract_nuclear_morphology + + +def _make_label_2d(): + """2-D label image with two non-overlapping squares as nuclei.""" + img = np.zeros((80, 80), dtype=np.int32) + img[5:25, 5:25] = 1 # nucleus 1: 20×20 = 400 pixels + img[5:25, 50:70] = 2 # nucleus 2: 20×20 = 400 pixels + return img + + +def _make_label_3d(): + """3-D label image with two non-overlapping cubes as nuclei.""" + img = np.zeros((10, 40, 40), dtype=np.int32) + img[2:8, 2:12, 2:12] = 1 # nucleus 1: 6×10×10 = 600 voxels + img[2:8, 25:35, 25:35] = 2 # nucleus 2: 6×10×10 = 600 voxels + return img + + +# --- 2-D tests --- + + +def test_2d_returns_one_row_per_label(): + df = extract_nuclear_morphology(_make_label_2d(), np.array([1, 2])) + assert len(df) == 2 + assert set(df["label"]) == {1, 2} + + +def test_2d_missing_label_is_dropped(): + df = extract_nuclear_morphology(_make_label_2d(), np.array([1, 99])) + assert list(df["label"]) == [1] + + +def test_2d_feature_columns_present(): + df = extract_nuclear_morphology(_make_label_2d(), np.array([1])) + expected = { + "area", "eccentricity", "equivalent_diameter_area", "extent", + "major_axis_length", "minor_axis_length", "orientation", + "perimeter", "solidity", "euler_number", "aspect_ratio", + } + assert expected.issubset(set(df.columns)) + + +def test_2d_area_matches_pixel_count(): + df = extract_nuclear_morphology(_make_label_2d(), np.array([1])) + assert df.loc[df["label"] == 1, "area"].item() == pytest.approx(400) + + +def test_2d_aspect_ratio_finite(): + df = extract_nuclear_morphology(_make_label_2d(), np.array([1, 2])) + assert np.all(np.isfinite(df["aspect_ratio"].to_numpy())) + + +# --- 3-D tests --- + + +def test_3d_returns_one_row_per_label(): + df = extract_nuclear_morphology(_make_label_3d(), np.array([1, 2])) + assert len(df) == 2 + + +def test_3d_inertia_eigval_columns_present(): + df = extract_nuclear_morphology(_make_label_3d(), np.array([1])) + eigval_cols = [c for c in df.columns if c.startswith("inertia_eigval_")] + assert len(eigval_cols) == 3 # 3-D has 3 eigenvalues + + +def test_3d_no_2d_only_columns(): + df = extract_nuclear_morphology(_make_label_3d(), np.array([1])) + # eccentricity and perimeter are 2-D only + assert "eccentricity" not in df.columns + assert "perimeter" not in df.columns diff --git a/packages/viscy-phenotyping/tests/profiler_test.py b/packages/viscy-phenotyping/tests/profiler_test.py new file mode 100644 index 000000000..41840550b --- /dev/null +++ b/packages/viscy-phenotyping/tests/profiler_test.py @@ -0,0 +1,264 @@ +"""Tests for all image-based phenotyping feature modules.""" + +import numpy as np +import pytest + +from viscy_phenotyping.features_density import density_features +from viscy_phenotyping.features_gradient import gradient_features +from viscy_phenotyping.features_radial import concentric_uniformity_features, radial_distribution_features +from viscy_phenotyping.features_shape import shape_features +from viscy_phenotyping.features_structure import structure_features +from viscy_phenotyping.features_texture import texture_features +from viscy_phenotyping.profiler import compute_cell_features + + +# ---------- shared fixtures ---------- + + +def _circle_mask(size=64, radius=20): + """Binary circular mask centred in a square image.""" + cy, cx = size // 2, size // 2 + y, x = np.ogrid[:size, :size] + return (np.hypot(y - cy, x - cx) < radius).astype(np.uint8) + + +def _gaussian_image(size=64, sigma=10.0): + """Gaussian blob image (float32).""" + cy, cx = size // 2, size // 2 + y, x = np.mgrid[:size, :size] + return np.exp(-((y - cy) ** 2 + (x - cx) ** 2) / (2 * sigma**2)).astype(np.float32) + + +def _ring_image(size=64, n_rings=3): + """Concentric ring pattern image (float32).""" + cy, cx = size // 2, size // 2 + y, x = np.mgrid[:size, :size] + r = np.hypot(y - cy, x - cx) + return (np.sin(r * n_rings * np.pi / (size / 2)) + 1).astype(np.float32) + + +def _spot_image(size=64, n_spots=10, rng_seed=0): + """Random spots image (float32).""" + rng = np.random.default_rng(rng_seed) + img = np.zeros((size, size), dtype=np.float32) + for _ in range(n_spots): + y, x = rng.integers(5, size - 5, 2) + img[y - 2 : y + 2, x - 2 : x + 2] = rng.uniform(0.5, 1.0) + return img + + +# ---------- Problem 1: radial distribution ---------- + + +def test_radial_distribution_keys(): + mask = _circle_mask() + img = _gaussian_image() + out = radial_distribution_features(img, mask) + assert all(f"radial_frac_bin{i}" in out for i in range(8)) + assert "com_offset_norm" in out + assert "angular_cv" in out + assert "radial_frac_cv" in out + assert "radial_slope" in out + + +def test_radial_slope_center_bright_is_positive(): + """Gaussian centred on nucleus → centre-bright → radial_slope > 0.""" + mask = _circle_mask() + img = _gaussian_image() + out = radial_distribution_features(img, mask) + assert out["radial_slope"] > 0 + + +def test_radial_slope_edge_bright_is_negative(): + """Ring at boundary → edge-bright → radial_slope < 0.""" + mask = _circle_mask() + # Ring-like image: signal concentrated near the edge of the patch + size = 64 + cy, cx = size // 2, size // 2 + y, x = np.mgrid[:size, :size] + r = np.hypot(y - cy, x - cx) + img = (r > 20).astype(np.float32) # bright ring at outer boundary + out = radial_distribution_features(img, mask) + assert out["radial_slope"] < 0 + + +def test_radial_distribution_gaussian_com_near_zero(): + """Gaussian centred on nucleus → centre-of-mass offset should be small.""" + mask = _circle_mask() + img = _gaussian_image() + out = radial_distribution_features(img, mask) + assert out["com_offset_norm"] < 0.2 + + +def test_radial_distribution_all_finite(): + mask = _circle_mask() + img = _gaussian_image() + out = radial_distribution_features(img, mask) + assert all(np.isfinite(v) for v in out.values()) + + +# ---------- Problem 3: concentric uniformity ---------- + + +def test_concentric_uniformity_keys(): + mask = _circle_mask() + img = _ring_image() + out = concentric_uniformity_features(img, mask) + assert "radial_profile_cv" in out + assert "radial_dominant_freq" in out + assert "radial_autocorr_lag1" in out + + +def test_uniform_image_low_profile_cv(): + """Uniform image → flat radial profile → low CV.""" + mask = _circle_mask() + img = np.ones((64, 64), dtype=np.float32) + out = concentric_uniformity_features(img, mask) + assert out["radial_profile_cv"] < 0.05 + + +# ---------- Problem 2: texture ---------- + + +def test_texture_keys(): + img = _gaussian_image() + out = texture_features(img) + assert "intensity_mean" in out + assert "intensity_median" in out + assert "intensity_cv" in out + assert "intensity_entropy" in out + assert "glcm_homogeneity_mean" in out + assert "lbp_entropy" in out + + +def test_texture_uniform_low_cv(): + img = np.ones((64, 64), dtype=np.float32) + out = texture_features(img) + assert out["intensity_cv"] < 1e-3 + + +# ---------- Problem 4: density ---------- + + +def test_density_keys(): + img = _spot_image() + out = density_features(img) + assert "binary_area_fraction" in out + assert "spot_count" in out + assert "granularity_1" in out + assert "granularity_8" in out + + +def test_density_empty_image_zero_spots(): + img = np.zeros((64, 64), dtype=np.float32) + out = density_features(img) + assert out["spot_count"] == 0.0 + + +# ---------- Problem 5: structure ---------- + + +def test_structure_keys(): + img = _spot_image() + out = structure_features(img) + assert "edge_density" in out + assert "skeleton_length" in out + assert "skeleton_branch_points" in out + assert "skeleton_endpoints" in out + assert "n_connected_components" in out + + +def test_structure_all_finite(): + img = _gaussian_image() + out = structure_features(img) + assert all(np.isfinite(v) for v in out.values()) + + +# ---------- Problem 6: shape ---------- + + +def test_shape_keys(): + mask = _circle_mask() + out = shape_features(mask) + assert "circularity" in out + assert "convexity" in out + assert "radial_std_norm" in out + assert all(f"fsd_{k}" in out for k in range(1, 7)) + + +def test_circle_circularity_near_one(): + mask = _circle_mask(size=128, radius=40) + out = shape_features(mask) + assert out["circularity"] == pytest.approx(1.0, abs=0.1) + + +def test_circle_low_radial_std(): + mask = _circle_mask(size=128, radius=40) + out = shape_features(mask) + assert out["radial_std_norm"] < 0.05 + + +# ---------- Problem 7: gradient ---------- + + +def test_gradient_keys(): + mask = _circle_mask() + img = _gaussian_image() + out = gradient_features(img, mask) + assert "gradient_mean" in out + assert "gradient_p95" in out + assert "laplacian_variance" in out + assert "nucleus_mean_intensity" in out + assert "cytoplasm_mean_intensity" in out + assert "nucleus_to_cytoplasm_ratio" in out + assert "gradient_entropy" in out + + +def test_nucleus_mean_intensity_higher_than_cytoplasm(): + """Gaussian centred on nucleus → nuclear pixels brighter than background.""" + mask = _circle_mask() + img = _gaussian_image() + out = gradient_features(img, mask) + assert out["nucleus_mean_intensity"] > out["cytoplasm_mean_intensity"] + + +def test_gradient_uniform_near_zero(): + mask = _circle_mask() + img = np.ones((64, 64), dtype=np.float32) + out = gradient_features(img, mask) + assert out["gradient_mean"] < 1e-5 + + +# ---------- profiler orchestrator ---------- + + +def test_compute_cell_features_returns_nonempty(): + label_patch = np.zeros((64, 64), dtype=np.int32) + label_patch[20:45, 20:45] = 7 + img_patch = np.stack([_gaussian_image(), _ring_image()]) # (2, 64, 64) + out = compute_cell_features(img_patch, label_patch, cell_id=7, channel_names=["ch0", "ch1"]) + assert len(out) > 0 + + +def test_compute_cell_features_channel_prefix(): + label_patch = np.zeros((64, 64), dtype=np.int32) + label_patch[20:45, 20:45] = 1 + img_patch = _gaussian_image()[np.newaxis] # (1, 64, 64) + out = compute_cell_features(img_patch, label_patch, cell_id=1, channel_names=["DAPI"]) + assert any(k.startswith("DAPI_") for k in out) + + +def test_compute_cell_features_shape_no_prefix(): + label_patch = np.zeros((64, 64), dtype=np.int32) + label_patch[20:45, 20:45] = 1 + img_patch = _gaussian_image()[np.newaxis] + out = compute_cell_features(img_patch, label_patch, cell_id=1, channel_names=["ch0"]) + # shape features have no channel prefix + assert "circularity" in out + + +def test_compute_cell_features_missing_cell_returns_empty(): + label_patch = np.zeros((64, 64), dtype=np.int32) + img_patch = _gaussian_image()[np.newaxis] + out = compute_cell_features(img_patch, label_patch, cell_id=99, channel_names=["ch0"]) + assert out == {} From 824c6face87a793ea2818677fd48752ccbbc42c2 Mon Sep 17 00:00:00 2001 From: Soorya Pradeep Date: Tue, 28 Apr 2026 10:55:07 -0700 Subject: [PATCH 2/2] Integrate cp-measure CellProfiler measurements into viscy-phenotyping Adds four CellProfiler measurement groups computed per cell patch: - cp_sizeshape_features: MeasureObjectSizeShape (78 features, mask-only, prefix cp_) - cp_intensity_features: MeasureObjectIntensity (21 features, per channel) - cp_texture_features: MeasureTexture/Haralick (52 features, per channel) - cp_granularity_features: MeasureGranularity (16 features, per channel) Texture images are min-max normalised to [0, 1] before passing to cp_measure to satisfy skimage.img_as_ubyte's float range requirement. Total features per cell per channel increases from ~84 to ~241. Co-Authored-By: Claude Sonnet 4.6 --- packages/viscy-phenotyping/FEATURES.md | 134 ++++++++ packages/viscy-phenotyping/pyproject.toml | 1 + .../viscy_phenotyping/features_cp_measure.py | 118 +++++++ .../src/viscy_phenotyping/profiler.py | 14 + uv.lock | 297 +++++++++++++----- 5 files changed, 481 insertions(+), 83 deletions(-) create mode 100644 packages/viscy-phenotyping/src/viscy_phenotyping/features_cp_measure.py diff --git a/packages/viscy-phenotyping/FEATURES.md b/packages/viscy-phenotyping/FEATURES.md index 54eb04ddb..7c650865d 100644 --- a/packages/viscy-phenotyping/FEATURES.md +++ b/packages/viscy-phenotyping/FEATURES.md @@ -248,3 +248,137 @@ raw_mCherry_EX561_EM600-37_intensity_cv Nuclear shape features (`circularity`, `convexity`, `radial_std_norm`, `fsd_1`…`fsd_6`) have no channel prefix. + +--- + +## CellProfiler Measurements (`cp-measure`) + +These features are computed using the +[cp-measure](https://github.com/afermg/cp_measure) library, which provides +faithful Python implementations of CellProfiler's measurement modules. They +complement the custom features above with established, widely-used morphological +and intensity descriptors. + +### Naming convention + +| Feature group | Prefix in CSV | +|---|---| +| MeasureObjectSizeShape | `cp_{feature}` | +| MeasureObjectIntensity | `{channel}_cp_{feature}` | +| MeasureTexture | `{channel}_cp_{feature}` | +| MeasureGranularity | `{channel}_cp_{feature}` | + +--- + +## cp MeasureObjectSizeShape + +**Source:** `features_cp_measure.py` — `cp_sizeshape_features(mask)` +**Input:** Binary nuclear mask (no intensity image) +**Prefix:** `cp_` + +| Feature | Description | +|---|---| +| `cp_Area` | Number of pixels in the nucleus. | +| `cp_BoundingBoxArea` | Area of the nucleus bounding box. | +| `cp_ConvexArea` | Area of the convex hull of the nucleus. | +| `cp_EquivalentDiameter` | Diameter of a circle with the same area as the nucleus. | +| `cp_Perimeter` | Perimeter length of the nucleus boundary. | +| `cp_PerimeterCrofton` | Perimeter estimated using the Crofton formula (more accurate for digital images). | +| `cp_MajorAxisLength` | Length of the major axis of the best-fit ellipse. | +| `cp_MinorAxisLength` | Length of the minor axis of the best-fit ellipse. | +| `cp_Eccentricity` | Eccentricity of the best-fit ellipse (0 = circle, 1 = line). | +| `cp_Orientation` | Angle of the major axis relative to the horizontal (degrees). | +| `cp_FormFactor` | 4π × Area / Perimeter². Equals 1.0 for a perfect circle. | +| `cp_Extent` | Nucleus area / bounding-box area. Low = non-compact shape. | +| `cp_Solidity` | Nucleus area / convex-hull area. Low = concave or irregular shape. | +| `cp_Compactness` | Mean squared distance from centroid to boundary, normalised by area. | +| `cp_EulerNumber` | Number of objects minus number of holes. | +| `cp_MaximumRadius` | Maximum distance from centroid to boundary. | +| `cp_MeanRadius` | Mean distance from centroid to boundary. | +| `cp_MedianRadius` | Median distance from centroid to boundary. | +| `cp_FilledArea` | Area after filling holes in the nucleus mask. | +| `cp_MinFeretDiameter` | Minimum caliper diameter (shortest span across the nucleus). | +| `cp_MaxFeretDiameter` | Maximum caliper diameter (longest span across the nucleus). | +| `cp_HuMoment_0` … `cp_HuMoment_6` | Seven Hu invariant moments — rotation-, scale-, and translation-invariant shape descriptors. | +| `cp_Zernike_n_m` | Zernike polynomial magnitudes up to degree 9. Orthogonal shape descriptors on the unit disk. | +| `cp_SpatialMoment_p_q` | Raw spatial moments of the binary mask. | +| `cp_CentralMoment_p_q` | Translation-invariant central moments. | +| `cp_NormalizedMoment_p_q` | Scale-invariant normalised central moments. | +| `cp_InertiaTensor_i_j` | Elements of the 2×2 inertia tensor. | +| `cp_InertiaTensorEigenvalues_0/1` | Principal moments of inertia (eigenvalues of the inertia tensor). | +| `cp_Center_X/Y` | Centroid coordinates (pixels, patch-relative). | +| `cp_BoundingBoxMinimum/Maximum_X/Y` | Bounding-box corner coordinates. | + +--- + +## cp MeasureObjectIntensity + +**Source:** `features_cp_measure.py` — `cp_intensity_features(image, mask)` +**Input:** Single-channel fluorescence patch; nuclear mask +**Prefix:** `{channel}_cp_` + +| Feature | Description | +|---|---| +| `Intensity_IntegratedIntensity` | Sum of all pixel intensities inside the nucleus. | +| `Intensity_MeanIntensity` | Mean intensity inside the nucleus. | +| `Intensity_StdIntensity` | Standard deviation of intensity inside the nucleus. | +| `Intensity_MinIntensity` | Minimum pixel intensity inside the nucleus. | +| `Intensity_MaxIntensity` | Maximum pixel intensity inside the nucleus. | +| `Intensity_MassDisplacement` | Distance between intensity centre-of-mass and geometric centroid, normalised by object radius. | +| `Intensity_LowerQuartileIntensity` | 25th-percentile intensity inside the nucleus. | +| `Intensity_MedianIntensity` | Median intensity inside the nucleus. | +| `Intensity_MADIntensity` | Median absolute deviation of intensities inside the nucleus. | +| `Intensity_UpperQuartileIntensity` | 75th-percentile intensity inside the nucleus. | +| `Intensity_IntegratedIntensityEdge` | Sum of pixel intensities on the nucleus boundary edge. | +| `Intensity_MeanIntensityEdge` | Mean intensity on the nucleus boundary edge. | +| `Intensity_StdIntensityEdge` | Standard deviation of intensity on the nucleus boundary edge. | +| `Intensity_MinIntensityEdge` | Minimum intensity on the nucleus boundary edge. | +| `Intensity_MaxIntensityEdge` | Maximum intensity on the nucleus boundary edge. | +| `Location_CenterMassIntensity_X/Y` | X/Y coordinates of the intensity-weighted centroid. | +| `Location_MaxIntensity_X/Y` | X/Y coordinates of the brightest pixel inside the nucleus. | + +--- + +## cp MeasureTexture + +**Source:** `features_cp_measure.py` — `cp_texture_features(image, mask)` +**Input:** Single-channel fluorescence patch; nuclear mask +**Prefix:** `{channel}_cp_` + +Haralick features computed from the Grey-Level Co-occurrence Matrix (GLCM) at +scale 3 px and 4 directions (0°, 45°, 90°, 135°), quantised to 256 grey levels. +Feature names follow the pattern `{Property}_{scale}_{direction}_{levels}`. + +| Feature | Description | +|---|---| +| `AngularSecondMoment_3_{dir}_256` | Uniformity of the GLCM (Angular Second Moment). High = repetitive or homogeneous texture. | +| `Contrast_3_{dir}_256` | Local intensity variation between neighbouring pixels. High = high-contrast, heterogeneous texture. | +| `Correlation_3_{dir}_256` | Linear correlation between neighbouring pixel grey levels. | +| `Variance_3_{dir}_256` | Variance of grey-level intensities in the GLCM. | +| `InverseDifferenceMoment_3_{dir}_256` | Homogeneity. High = similar neighbouring pixels (smooth texture). | +| `SumAverage_3_{dir}_256` | Mean of the sum of grey-level pairs. | +| `SumVariance_3_{dir}_256` | Variance of the sum of grey-level pairs. | +| `SumEntropy_3_{dir}_256` | Entropy of the sum distribution. | +| `Entropy_3_{dir}_256` | Shannon entropy of the full GLCM. High = complex, non-repetitive texture. | +| `DifferenceVariance_3_{dir}_256` | Variance of the difference between grey-level pairs. | +| `DifferenceEntropy_3_{dir}_256` | Entropy of the difference distribution. | +| `InfoMeas1_3_{dir}_256` | Information measure of correlation 1 (HXY1). | +| `InfoMeas2_3_{dir}_256` | Information measure of correlation 2 (HXY2). | + +Directions: `00` = 0°, `01` = 45°, `02` = 90°, `03` = 135°. + +--- + +## cp MeasureGranularity + +**Source:** `features_cp_measure.py` — `cp_granularity_features(image, mask)` +**Input:** Single-channel fluorescence patch; nuclear mask +**Prefix:** `{channel}_cp_` + +The granularity spectrum quantifies the size distribution of bright structures +by applying morphological opening at increasing scales and measuring the fraction +of signal removed at each scale. + +| Feature | Description | +|---|---| +| `Granularity_1` … `Granularity_16` | Fraction of total image intensity removed by morphological opening at scale r = 1..16 pixels. High at small r = fine-grained puncta or dense small spots. High at large r = coarse or large bright regions. The peak of the spectrum indicates the dominant size scale of bright structures in the patch. | diff --git a/packages/viscy-phenotyping/pyproject.toml b/packages/viscy-phenotyping/pyproject.toml index 9ef9b9734..fba76fd23 100644 --- a/packages/viscy-phenotyping/pyproject.toml +++ b/packages/viscy-phenotyping/pyproject.toml @@ -12,6 +12,7 @@ requires-python = ">=3.11" dynamic = [ "version" ] dependencies = [ "click", + "cp-measure", "iohub>=0.3a2", "numpy>=2.4.1", "pandas", diff --git a/packages/viscy-phenotyping/src/viscy_phenotyping/features_cp_measure.py b/packages/viscy-phenotyping/src/viscy_phenotyping/features_cp_measure.py new file mode 100644 index 000000000..f77779a97 --- /dev/null +++ b/packages/viscy-phenotyping/src/viscy_phenotyping/features_cp_measure.py @@ -0,0 +1,118 @@ +"""CellProfiler-style measurements via the cp-measure library. + +Wraps four core cp-measure groups for use on single-cell image patches: +- intensity : MeasureObjectIntensity (per channel) +- sizeshape : MeasureObjectSizeShape (channel-independent, mask only) +- texture : MeasureTexture / Haralick (per channel) +- granularity : MeasureGranularity (per channel) + +All functions take a 2-D single-channel image patch and/or a binary nuclear +mask and return a flat ``dict[str, float]`` with one value per feature. +The caller is responsible for adding channel prefixes. +""" + +import warnings + +import numpy as np + +__all__ = [ + "cp_intensity_features", + "cp_sizeshape_features", + "cp_texture_features", + "cp_granularity_features", +] + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", SyntaxWarning) + from cp_measure.bulk import get_core_measurements + +_CORE = get_core_measurements() + + +def _extract(result: dict, label_idx: int = 0) -> dict[str, float]: + """Extract scalar at label_idx from each per-object result array.""" + return {k: float(v[label_idx]) for k, v in result.items()} + + +def cp_intensity_features(image: np.ndarray, mask: np.ndarray) -> dict[str, float]: + """CellProfiler MeasureObjectIntensity features (per channel). + + Parameters + ---------- + image : np.ndarray, shape (Y, X) + Single-channel fluorescence patch. + mask : np.ndarray, shape (Y, X), bool or int + Binary nuclear mask for the target cell. + + Returns + ------- + dict[str, float] + Keys follow CellProfiler naming: ``Intensity_MeanIntensity``, + ``Intensity_StdIntensity``, etc. + """ + cell_mask = mask.astype(np.int32) + return _extract(_CORE["intensity"](cell_mask, image.astype(np.float64))) + + +def cp_sizeshape_features(mask: np.ndarray) -> dict[str, float]: + """CellProfiler MeasureObjectSizeShape features (channel-independent). + + Parameters + ---------- + mask : np.ndarray, shape (Y, X), bool or int + Binary nuclear mask for the target cell. + + Returns + ------- + dict[str, float] + Keys follow CellProfiler naming: ``Area``, ``Perimeter``, + ``Eccentricity``, ``FormFactor``, etc. + """ + cell_mask = mask.astype(np.int32) + return _extract(_CORE["sizeshape"](cell_mask, None)) + + +def cp_texture_features(image: np.ndarray, mask: np.ndarray) -> dict[str, float]: + """CellProfiler MeasureTexture (Haralick) features (per channel). + + Parameters + ---------- + image : np.ndarray, shape (Y, X) + Single-channel fluorescence patch. + mask : np.ndarray, shape (Y, X), bool or int + Binary nuclear mask for the target cell. + + Returns + ------- + dict[str, float] + Keys follow CellProfiler naming: ``AngularSecondMoment_3_00_256``, + ``Contrast_3_00_256``, ``Entropy_3_00_256``, etc. + """ + cell_mask = mask.astype(np.int32) + img = image.astype(np.float64) + # cp_measure's texture module calls skimage.img_as_ubyte which requires [0, 1] + lo, hi = img.min(), img.max() + if hi > lo: + img = (img - lo) / (hi - lo) + else: + img = np.zeros_like(img) + return _extract(_CORE["texture"](cell_mask, img)) + + +def cp_granularity_features(image: np.ndarray, mask: np.ndarray) -> dict[str, float]: + """CellProfiler MeasureGranularity features (per channel). + + Parameters + ---------- + image : np.ndarray, shape (Y, X) + Single-channel fluorescence patch. + mask : np.ndarray, shape (Y, X), bool or int + Binary nuclear mask for the target cell. + + Returns + ------- + dict[str, float] + Keys follow CellProfiler naming: ``Granularity_1`` … ``Granularity_16``. + """ + cell_mask = mask.astype(np.int32) + return _extract(_CORE["granularity"](cell_mask, image.astype(np.float64))) diff --git a/packages/viscy-phenotyping/src/viscy_phenotyping/profiler.py b/packages/viscy-phenotyping/src/viscy_phenotyping/profiler.py index 77d31b8b8..a88c0300f 100644 --- a/packages/viscy-phenotyping/src/viscy_phenotyping/profiler.py +++ b/packages/viscy-phenotyping/src/viscy_phenotyping/profiler.py @@ -2,6 +2,12 @@ import numpy as np +from viscy_phenotyping.features_cp_measure import ( + cp_granularity_features, + cp_intensity_features, + cp_sizeshape_features, + cp_texture_features, +) from viscy_phenotyping.features_density import density_features from viscy_phenotyping.features_gradient import gradient_features from viscy_phenotyping.features_radial import concentric_uniformity_features, radial_distribution_features @@ -47,6 +53,9 @@ def compute_cell_features( # Problem 6: nuclear shape — mask only, no channel prefix features.update(shape_features(mask)) + # cp-measure: MeasureObjectSizeShape — mask only, no channel prefix + features.update({"cp_" + k: v for k, v in cp_sizeshape_features(mask).items()}) + for ch_idx, ch_name in enumerate(channel_names): ch_img = img_patch[ch_idx].astype(np.float32) prefix = ch_name.replace(" ", "_") + "_" @@ -64,4 +73,9 @@ def compute_cell_features( # Problem 7: gradient changes (full patch; mask used only for signal_to_background) features.update({prefix + k: v for k, v in gradient_features(ch_img, mask).items()}) + # cp-measure: MeasureObjectIntensity, MeasureTexture, MeasureGranularity + features.update({prefix + "cp_" + k: v for k, v in cp_intensity_features(ch_img, mask).items()}) + features.update({prefix + "cp_" + k: v for k, v in cp_texture_features(ch_img, mask).items()}) + features.update({prefix + "cp_" + k: v for k, v in cp_granularity_features(ch_img, mask).items()}) + return features diff --git a/uv.lock b/uv.lock index dddc37340..737e66fac 100644 --- a/uv.lock +++ b/uv.lock @@ -46,6 +46,7 @@ members = [ "viscy", "viscy-data", "viscy-models", + "viscy-phenotyping", "viscy-transforms", "viscy-utils", ] @@ -478,6 +479,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/cf/d7de46b5268d5bf43fba02d8ace65d4c4064d8761c559cc8fb3bab617e7a/cellpose-4.0.9-py3-none-any.whl", hash = "sha256:bc0dacce83074fab2ff90f4cd2b0e9f7e65b528affa139caa37be6961f2e19cf", size = 213074, upload-time = "2026-03-04T19:52:11.8Z" }, ] +[[package]] +name = "centrosome" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecation" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "scikit-image" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/ea/ae6cd66b100b9ce1c8107230ac211b7de529dc15153f7c03e37e274ba47c/centrosome-1.3.3.tar.gz", hash = "sha256:bf808f3f339d9bf88c85c0f32b9039419080fe73991d90b814ddea9fc7e94658", size = 1220305, upload-time = "2026-01-14T18:57:58.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/b1/6db81df724fe9becb39f56b7d30a854161737467560a938e01743c0c2ab6/centrosome-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ab2741b126adfa1fc92f13cd345bf8d73c22ae058d54a81ae9ea237eee028834", size = 373354, upload-time = "2026-01-14T18:57:38.861Z" }, + { url = "https://files.pythonhosted.org/packages/c5/30/895d4d2255e803e76ca20b592ca370d22d4369581dd77a41e01dbd95acac/centrosome-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b0f6161fe55bf90ea0c0565863a727f5a159b503343736ed74bf1c97b2352ee3", size = 356649, upload-time = "2026-01-14T18:57:39.935Z" }, + { url = "https://files.pythonhosted.org/packages/3b/64/c0372c8e17d94f388577aec47725e8c77c82eb1f4996dc361b7b4f03bd78/centrosome-1.3.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f455523759e8d0392af1b0f5bf0dfb5fdacd3f67da195fb941210d6aad84343", size = 1858657, upload-time = "2026-01-14T18:57:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b8/83b53ab1d61b65c5bc200fa6f9b827381e9a68a13ab37675912c6b64661b/centrosome-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:0968bb2599905d7245994257fe6d53da38d22a740f68a46da0661b34a9662db4", size = 364554, upload-time = "2026-01-14T18:57:42.408Z" }, + { url = "https://files.pythonhosted.org/packages/41/20/3c330627a566fbf93f33ce4cbe2e34946b4eff4f175c668e836db1a8d16f/centrosome-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61dca02511a174546c629770cda26fc8d4d1153a763b9f58596ce17c0b9ae583", size = 369182, upload-time = "2026-01-14T18:57:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/5f/95/5f97fdb2d768a7d2fd87dd33db844ec12c9ed6cd4d5a3c6e82f3d7ec9c00/centrosome-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f485bfab877b92a3e2e70f21de37d1d72303062a8a54866b7dccc7de96ec1a08", size = 354590, upload-time = "2026-01-14T18:57:44.337Z" }, + { url = "https://files.pythonhosted.org/packages/38/59/e0e6ef4f4c37751e54849c74f9418754e6fb0a8e828b2afb75591d916f41/centrosome-1.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0ed8ab8747819efae45c90ee345ab509ee89b1257016cc5421b0aff12202b979", size = 1979184, upload-time = "2026-01-14T18:57:45.303Z" }, + { url = "https://files.pythonhosted.org/packages/9f/80/45186f9ea0a696a9c86c9e73d665260bd4a3e20affc14d9bd9bb25922eac/centrosome-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:6eb7094fc8eb7b0ed7afc36c22cd88ac6b417ead4014944e79275d971fdaabf1", size = 362267, upload-time = "2026-01-14T18:57:46.404Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7e/2ee69d6b4902f1a5c11b05848eac1d9e919ee25409a75fc7bad589070dba/centrosome-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a106b7f715183a28df84f81dc2421ef857cc4cf20661a63c5766ce5d8dc49895", size = 367545, upload-time = "2026-01-14T18:57:47.342Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b6/dda351d1d96d6eb4520b31da5eac244727f9966b92889ca90f4a6ca8a671/centrosome-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd6e53f7bea13a7adc03afabf608cf5015190367f75e81eb2372d040acbb3048", size = 352763, upload-time = "2026-01-14T18:57:48.45Z" }, + { url = "https://files.pythonhosted.org/packages/04/45/47c16d8538d2c2f5e8dacb56c867ec80290e8bbb444066470f1c29a57415/centrosome-1.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f217ee70795069af225228449a71b7db06f5adf59ab98ecc3648836705a20969", size = 1884830, upload-time = "2026-01-14T18:57:49.415Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f3/dfaca58986c665bd5a3a8af26c51bda8be63c412cde89217059387f58b05/centrosome-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:9b8d87ffa40b0c92b5eeabcbdb1abfdbe89f7a974e38a3a9a98667916500d319", size = 361063, upload-time = "2026-01-14T18:57:50.597Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -894,6 +922,22 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cp-measure" +version = "0.1.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "centrosome" }, + { name = "mahotas" }, + { name = "numpy" }, + { name = "scikit-image" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/6a/f8301cfc560a4b9b6d702c0b506573261684eb08c98dc45d8fe9ed293f71/cp_measure-0.1.19.tar.gz", hash = "sha256:829144998eec3d7c8ac4ca15dea01814afa332612be96d5d025d13c4aa4eaa7b", size = 56993, upload-time = "2026-04-22T14:57:05.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/2d/4291033cce9fda4c6375188035ca81adfe174926f23b490af02b8306aef5/cp_measure-0.1.19-py3-none-any.whl", hash = "sha256:036596f6e9750f0dc36b7e3dccdc5d4ada6451f92125aa73ff08153c7d27ed3e", size = 60128, upload-time = "2026-04-22T14:57:04.579Z" }, +] + [[package]] name = "cuda-bindings" version = "12.9.4" @@ -1063,6 +1107,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, ] +[[package]] +name = "deprecation" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload-time = "2020-04-20T14:23:38.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, +] + [[package]] name = "diffusers" version = "0.37.1" @@ -2702,6 +2758,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl", hash = "sha256:b6c819a722f7b6bd955b80781788e4a66a55628b858d347536b7e81325a3a5e3", size = 4398, upload-time = "2022-04-20T22:04:42.23Z" }, ] +[[package]] +name = "mahotas" +version = "1.4.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/71/bf99df8458c0ca05cb9a16f400e66c09b37b15ea124aaa3becb577555cc5/mahotas-1.4.18.tar.gz", hash = "sha256:e6bd2eea4143a24f381b30c64078503cd8ffa20ca493e39ffa29f9d024d9cf8b", size = 1533222, upload-time = "2024-07-17T21:11:56.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/15/fab81001a735766f8bbe7080e714b9582817bd479b915977e748199f00d9/mahotas-1.4.18-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:974050ee67913ac2396b4889247577f7202038dc328b50a07f83887c56ca9774", size = 1877309, upload-time = "2024-07-17T21:10:30.758Z" }, + { url = "https://files.pythonhosted.org/packages/4f/f2/3125072f76b7809bd66748b2f6872dbeb0e72f43fdd94e73a9d3df95aa4c/mahotas-1.4.18-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28c93bdfefd4cf271bf2b30b69c130ab3cac5d840dcd3b5ae6e7f6d3648533aa", size = 1798029, upload-time = "2024-07-17T21:10:33.063Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/691ed6d7aedaf8caa30786e88462edd84e78d2b50d320a07937e2ce41fb1/mahotas-1.4.18-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f4a41dd3b49bc2e5240b265b9ab35a6793c20ddcb3b392f3ba27a0940086de6", size = 5804451, upload-time = "2024-07-17T21:10:36.365Z" }, + { url = "https://files.pythonhosted.org/packages/85/45/eded44b0d6d1e4642c87eb79b6f568b8a2cbe7183dbb6aec185ea6a54786/mahotas-1.4.18-cp311-cp311-win32.whl", hash = "sha256:a4e70ead2a2bf6e8ad9a70f9c33fe0e752edeeea1fc5e8e934efcf29d90d69f2", size = 1712194, upload-time = "2024-07-17T21:10:39.565Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0c/3710525e4d3a2cb28852cb77878d8268e3e56c52cdb4018972685a11e6cd/mahotas-1.4.18-cp311-cp311-win_amd64.whl", hash = "sha256:7a9a7b2a9e3e9d9818a901232fc68a2f7bef31483150ac39acb7d56f86e0754c", size = 1731319, upload-time = "2024-07-17T21:10:41.922Z" }, + { url = "https://files.pythonhosted.org/packages/cb/78/07865c11f24f539e05ad951e261051d1177f8c4432fb1e230d9d8e9132a2/mahotas-1.4.18-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5314778e8154fb69ddb299e07c48d1998eb3e9567724e93d5018940854975204", size = 1875870, upload-time = "2024-07-17T21:10:46.503Z" }, + { url = "https://files.pythonhosted.org/packages/1d/84/d325af34ce1c977f71503d5bddb5798cfaddbcdd30047caafcf013b2daf2/mahotas-1.4.18-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fe05bf5ee3498cd9411adf7c9fb8e6278194a04a1491c1a6d807658d4af36bb4", size = 1795431, upload-time = "2024-07-17T21:10:49.136Z" }, + { url = "https://files.pythonhosted.org/packages/3b/04/ebfd6f54a5919a4f344e498a541c26ca1dbf5e7628f464cbf35ea580308a/mahotas-1.4.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b6a5420fd71227cd3e875e42a01818b3d94cacb075f93afed36fab63390b75", size = 5817840, upload-time = "2024-07-17T21:10:51.901Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7f/1b89107058873a36e0aa0edd03dbbc18c647c3b672f391270d8b7f056f37/mahotas-1.4.18-cp312-cp312-win32.whl", hash = "sha256:839787784f916c4f03a43e92bb22184920213e5ece0fa0c826f5bdf92eaaff4b", size = 1712598, upload-time = "2024-07-17T21:10:54.487Z" }, + { url = "https://files.pythonhosted.org/packages/a7/93/8ab4c4e10235b0acef65561a33b303b66d2d1fd180545872798db05f0e09/mahotas-1.4.18-cp312-cp312-win_amd64.whl", hash = "sha256:579e6f48549b06eb32cfa9501e320194a9d8a97fe35a7832b6b1f3fa104cfb16", size = 1730892, upload-time = "2024-07-17T21:10:56.55Z" }, +] + [[package]] name = "markdown" version = "3.10.2" @@ -3953,89 +4030,86 @@ wheels = [ [[package]] name = "pillow" -version = "12.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, - { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, - { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, - { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, - { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, - { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, - { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, - { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, - { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, - { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, - { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, - { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, - { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, - { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, - { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, - { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, - { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, - { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, - { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, - { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, - { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, - { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, - { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, - { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, - { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, - { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, - { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, - { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, - { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, - { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, - { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, - { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, - { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, - { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, - { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, - { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, - { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, - { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, - { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, - { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, - { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, - { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, - { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, - { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, - { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, - { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, - { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, - { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, - { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, - { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, - { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, +version = "11.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531, upload-time = "2025-07-01T09:13:59.203Z" }, + { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560, upload-time = "2025-07-01T09:14:01.101Z" }, + { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978, upload-time = "2025-07-03T13:09:55.638Z" }, + { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168, upload-time = "2025-07-03T13:10:00.37Z" }, + { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053, upload-time = "2025-07-01T09:14:04.491Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273, upload-time = "2025-07-01T09:14:06.235Z" }, + { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043, upload-time = "2025-07-01T09:14:07.978Z" }, + { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516, upload-time = "2025-07-01T09:14:10.233Z" }, + { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768, upload-time = "2025-07-01T09:14:11.921Z" }, + { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055, upload-time = "2025-07-01T09:14:13.623Z" }, + { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079, upload-time = "2025-07-01T09:14:15.268Z" }, + { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, + { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, + { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, + { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, + { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, + { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, + { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566, upload-time = "2025-07-01T09:16:19.801Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618, upload-time = "2025-07-01T09:16:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248, upload-time = "2025-07-03T13:11:20.738Z" }, + { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963, upload-time = "2025-07-03T13:11:26.283Z" }, + { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170, upload-time = "2025-07-01T09:16:23.762Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505, upload-time = "2025-07-01T09:16:25.593Z" }, + { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" }, ] [[package]] @@ -6547,6 +6621,63 @@ test = [ { name = "pytest-cov", specifier = ">=7" }, ] +[[package]] +name = "viscy-phenotyping" +source = { editable = "packages/viscy-phenotyping" } +dependencies = [ + { name = "click" }, + { name = "cp-measure" }, + { name = "iohub" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "scikit-image" }, + { name = "scipy" }, + { name = "viscy-data" }, +] + +[package.optional-dependencies] +analysis = [ + { name = "anndata" }, + { name = "scikit-learn" }, + { name = "seaborn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, +] +test = [ + { name = "pytest" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "anndata", marker = "extra == 'analysis'" }, + { name = "click" }, + { name = "cp-measure" }, + { name = "iohub", specifier = ">=0.3a2" }, + { name = "numpy", specifier = ">=2.4.1" }, + { name = "pandas" }, + { name = "scikit-image" }, + { name = "scikit-learn", marker = "extra == 'analysis'" }, + { name = "scipy" }, + { name = "seaborn", marker = "extra == 'analysis'" }, + { name = "viscy-data", editable = "packages/viscy-data" }, +] +provides-extras = ["analysis"] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-cov", specifier = ">=7" }, +] +test = [ + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-cov", specifier = ">=7" }, +] + [[package]] name = "viscy-transforms" source = { editable = "packages/viscy-transforms" }