arcadia-microscopy-tools 0.2.2__tar.gz → 0.2.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/CHANGELOG.md +24 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/PKG-INFO +4 -4
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/pyproject.toml +4 -4
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/masks.py +107 -91
- arcadia_microscopy_tools-0.2.3/src/arcadia_microscopy_tools/microplate.py +251 -0
- arcadia_microscopy_tools-0.2.3/src/arcadia_microscopy_tools/tests/test_microplate.py +55 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/uv.lock +128 -117
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/.github/workflows/lint.yml +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/.github/workflows/publish.yml +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/.github/workflows/test.yml +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/.gitignore +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/.pre-commit-config.yaml +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/LICENSE +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/Makefile +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/README.md +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/docs/.gitignore +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/docs/Makefile +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/docs/_assets/logo.png +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/docs/_static/css/label.css +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/docs/conf.py +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/docs/examples/README.md +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/docs/examples/basic_usage.ipynb +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/docs/examples/cell_segmentation.ipynb +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/docs/examples/fluorescence_overlays.ipynb +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/docs/examples/index.md +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/docs/index.md +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/docs/install.md +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/docs/license/index.md +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/__init__.py +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/blending.py +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/channels.py +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/metadata_structures.py +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/microscopy.py +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/model.py +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/nikon.py +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/operations.py +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/pipeline.py +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/tests/__init__.py +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/tests/conftest.py +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/tests/data/README.md +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/tests/data/example-cerevisiae.nd2 +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/tests/data/example-multichannel.nd2 +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/tests/data/example-pbmc.nd2 +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/tests/data/example-timelapse.nd2 +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/tests/data/example-zstack.nd2 +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/tests/data/known-metadata.yml +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/tests/test_channels.py +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/tests/test_microscopy.py +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/tests/test_model.py +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/typing.py +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/utils.py +0 -0
|
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.2.3] - 2026-01-22
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- New `microplate.py` module for managing multiwell plate layouts:
|
|
12
|
+
- `Well` class: Represents individual wells with ID normalization (e.g., "a1" → "A01"), sample tracking, and custom properties
|
|
13
|
+
- `MicroplateLayout` class: Manages complete plate layouts with features:
|
|
14
|
+
- Load layouts from CSV files with `from_csv()`
|
|
15
|
+
- Display plate layouts as formatted grid tables with `display()`
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- Refactored `masks.py` architecture for improved performance and maintainability:
|
|
19
|
+
- Refactored `MaskProcessor` class into standalone `_process_mask()` function
|
|
20
|
+
- Updated `DEFAULT_CELL_PROPERTY_NAMES` to include circularity and volume by default
|
|
21
|
+
- Made cellpose and modal optional dependencies to reduce installation size:
|
|
22
|
+
- Install with `uv pip install arcadia-microscopy-tools[segmentation]` for cellpose support
|
|
23
|
+
- Install with `uv pip install arcadia-microscopy-tools[all]` for all optional dependencies
|
|
24
|
+
- Moved pytest from main dependencies to dev group
|
|
25
|
+
- Consolidated all dev tools into single `dev` dependency group
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- Coordinate format inconsistency in outline extractors: both cellpose and skimage now return outlines in (y, x) format
|
|
29
|
+
- `isinstance` check in `SegmentationMask` now accepts `Mapping` type instead of just `dict` to match type annotation
|
|
30
|
+
- Empty outline arrays now properly shaped as (0, 2) for consistency
|
|
31
|
+
|
|
8
32
|
## [0.2.2] - 2026-01-08
|
|
9
33
|
|
|
10
34
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arcadia-microscopy-tools
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Python package for processing large-scale microscopy datasets generated by Arcadia's imaging suite
|
|
5
5
|
License: MIT License
|
|
6
6
|
|
|
@@ -29,10 +29,9 @@ Requires-Dist: arcadia-pycolor>=0.6.5
|
|
|
29
29
|
Requires-Dist: colour-science>=0.4.7
|
|
30
30
|
Requires-Dist: liffile>=2025.12.12
|
|
31
31
|
Requires-Dist: nd2>=0.10.3
|
|
32
|
-
Requires-Dist: numpy>=2.4
|
|
32
|
+
Requires-Dist: numpy>=2.4.1
|
|
33
|
+
Requires-Dist: pandas>=2.3.3
|
|
33
34
|
Requires-Dist: scikit-image>=0.25.2
|
|
34
|
-
Requires-Dist: scikit-learn>=1.7.1
|
|
35
|
-
Requires-Dist: torch>=2.7.1
|
|
36
35
|
Provides-Extra: all
|
|
37
36
|
Requires-Dist: cellpose>=4.0.6; extra == 'all'
|
|
38
37
|
Requires-Dist: modal; extra == 'all'
|
|
@@ -40,6 +39,7 @@ Provides-Extra: compute
|
|
|
40
39
|
Requires-Dist: modal; extra == 'compute'
|
|
41
40
|
Provides-Extra: segmentation
|
|
42
41
|
Requires-Dist: cellpose>=4.0.6; extra == 'segmentation'
|
|
42
|
+
Requires-Dist: torch>=2.7.1; extra == 'segmentation'
|
|
43
43
|
Description-Content-Type: text/markdown
|
|
44
44
|
|
|
45
45
|
# arcadia-microscopy-tools
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "arcadia-microscopy-tools"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.3"
|
|
8
8
|
description = "Python package for processing large-scale microscopy datasets generated by Arcadia's imaging suite"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -14,15 +14,15 @@ dependencies = [
|
|
|
14
14
|
"colour-science>=0.4.7",
|
|
15
15
|
"liffile>=2025.12.12",
|
|
16
16
|
"nd2>=0.10.3",
|
|
17
|
-
"numpy>=2.4",
|
|
17
|
+
"numpy>=2.4.1",
|
|
18
|
+
"pandas>=2.3.3",
|
|
18
19
|
"scikit-image>=0.25.2",
|
|
19
|
-
"scikit-learn>=1.7.1",
|
|
20
|
-
"torch>=2.7.1",
|
|
21
20
|
]
|
|
22
21
|
|
|
23
22
|
[project.optional-dependencies]
|
|
24
23
|
segmentation = [
|
|
25
24
|
"cellpose>=4.0.6",
|
|
25
|
+
"torch>=2.7.1",
|
|
26
26
|
]
|
|
27
27
|
compute = [
|
|
28
28
|
"modal",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
import warnings
|
|
3
|
+
from collections.abc import Mapping
|
|
3
4
|
from dataclasses import dataclass, field
|
|
4
5
|
from functools import cached_property
|
|
5
6
|
from typing import Literal
|
|
@@ -9,24 +10,21 @@ import skimage as ski
|
|
|
9
10
|
from cellpose.utils import outlines_list
|
|
10
11
|
|
|
11
12
|
from .channels import Channel
|
|
12
|
-
from .typing import BoolArray, Int64Array, ScalarArray
|
|
13
|
-
|
|
14
|
-
OutlineExtractorMethod = Literal["cellpose", "skimage"]
|
|
13
|
+
from .typing import BoolArray, FloatArray, Int64Array, ScalarArray, UInt16Array
|
|
15
14
|
|
|
16
15
|
DEFAULT_CELL_PROPERTY_NAMES = [
|
|
17
16
|
"label",
|
|
18
17
|
"centroid",
|
|
18
|
+
"volume",
|
|
19
19
|
"area",
|
|
20
20
|
"area_convex",
|
|
21
21
|
"perimeter",
|
|
22
22
|
"eccentricity",
|
|
23
|
+
"circularity",
|
|
23
24
|
"solidity",
|
|
24
25
|
"axis_major_length",
|
|
25
26
|
"axis_minor_length",
|
|
26
27
|
"orientation",
|
|
27
|
-
"moments_hu",
|
|
28
|
-
"inertia_tensor",
|
|
29
|
-
"inertia_tensor_eigvals",
|
|
30
28
|
]
|
|
31
29
|
|
|
32
30
|
DEFAULT_INTENSITY_PROPERTY_NAMES = [
|
|
@@ -36,62 +34,70 @@ DEFAULT_INTENSITY_PROPERTY_NAMES = [
|
|
|
36
34
|
"intensity_std",
|
|
37
35
|
]
|
|
38
36
|
|
|
37
|
+
OutlineExtractorMethod = Literal["cellpose", "skimage"]
|
|
39
38
|
|
|
40
|
-
class CellposeOutlineExtractor:
|
|
41
|
-
"""Extract cell outlines using Cellpose's outlines_list function."""
|
|
42
|
-
|
|
43
|
-
def extract_outlines(self, label_image: Int64Array) -> list[ScalarArray]:
|
|
44
|
-
"""Extract outlines from label image."""
|
|
45
|
-
return outlines_list(label_image, multiprocessing=False)
|
|
46
39
|
|
|
40
|
+
def _process_mask(
|
|
41
|
+
mask_image: BoolArray | Int64Array,
|
|
42
|
+
remove_edge_cells: bool,
|
|
43
|
+
) -> Int64Array:
|
|
44
|
+
"""Process a mask image by optionally removing edge cells and ensuring consecutive labels.
|
|
47
45
|
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
Args:
|
|
47
|
+
mask_image: Input mask array where each cell has a unique label.
|
|
48
|
+
remove_edge_cells: Whether to remove cells touching image borders.
|
|
50
49
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
50
|
+
Returns:
|
|
51
|
+
Processed label image with consecutive labels starting from 1.
|
|
52
|
+
"""
|
|
53
|
+
label_image = mask_image
|
|
54
|
+
if remove_edge_cells:
|
|
55
|
+
label_image = ski.segmentation.clear_border(label_image)
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
contours = ski.measure.find_contours(cell_mask, level=0.5)
|
|
61
|
-
if contours:
|
|
62
|
-
main_contour = max(contours, key=len)
|
|
63
|
-
outlines.append(main_contour)
|
|
64
|
-
else:
|
|
65
|
-
outlines.append(np.array([]))
|
|
66
|
-
return outlines
|
|
57
|
+
# Ensure consecutive labels
|
|
58
|
+
label_image = ski.measure.label(label_image).astype(np.int64) # type: ignore
|
|
59
|
+
return label_image
|
|
67
60
|
|
|
68
61
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
"""Process segmentation masks by removing edge cells and ensuring consecutive labels.
|
|
62
|
+
def _extract_outlines_cellpose(label_image: Int64Array) -> list[FloatArray]:
|
|
63
|
+
"""Extract cell outlines using Cellpose's outlines_list function.
|
|
72
64
|
|
|
73
65
|
Args:
|
|
74
|
-
|
|
75
|
-
"""
|
|
66
|
+
label_image: 2D integer array where each cell has a unique label.
|
|
76
67
|
|
|
77
|
-
|
|
68
|
+
Returns:
|
|
69
|
+
List of arrays, one per cell, containing outline coordinates in (y, x) format.
|
|
70
|
+
"""
|
|
71
|
+
return outlines_list(label_image, multiprocessing=False)
|
|
78
72
|
|
|
79
|
-
def process_mask(self, mask_image: ScalarArray) -> Int64Array:
|
|
80
|
-
"""Process a mask image by optionally removing edge cells and ensuring consecutive labels.
|
|
81
73
|
|
|
82
|
-
|
|
83
|
-
|
|
74
|
+
def _extract_outlines_skimage(label_image: Int64Array) -> list[FloatArray]:
|
|
75
|
+
"""Extract cell outlines using scikit-image's find_contours.
|
|
84
76
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
"""
|
|
88
|
-
_label_image = mask_image.copy()
|
|
89
|
-
if self.remove_edge_cells:
|
|
90
|
-
_label_image = ski.segmentation.clear_border(_label_image)
|
|
77
|
+
Args:
|
|
78
|
+
label_image: 2D integer array where each cell has a unique label.
|
|
91
79
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
80
|
+
Returns:
|
|
81
|
+
List of arrays, one per cell, containing outline coordinates in (y, x) format.
|
|
82
|
+
Empty arrays are included for cells where no contours are found.
|
|
83
|
+
"""
|
|
84
|
+
# Get unique cell IDs (excluding background)
|
|
85
|
+
unique_labels = np.unique(label_image)
|
|
86
|
+
unique_labels = unique_labels[unique_labels > 0]
|
|
87
|
+
|
|
88
|
+
outlines = []
|
|
89
|
+
for cell_id in unique_labels:
|
|
90
|
+
cell_mask = (label_image == cell_id).astype(np.uint8)
|
|
91
|
+
contours = ski.measure.find_contours(cell_mask, level=0.5)
|
|
92
|
+
if contours:
|
|
93
|
+
main_contour = max(contours, key=len)
|
|
94
|
+
# Flip from (x, y) to (y, x) to match cellpose format
|
|
95
|
+
main_contour = main_contour[:, [1, 0]]
|
|
96
|
+
outlines.append(main_contour)
|
|
97
|
+
else:
|
|
98
|
+
# Include empty array to maintain alignment with cell labels
|
|
99
|
+
outlines.append(np.array([]).reshape(0, 2))
|
|
100
|
+
return outlines
|
|
95
101
|
|
|
96
102
|
|
|
97
103
|
@dataclass
|
|
@@ -99,27 +105,29 @@ class SegmentationMask:
|
|
|
99
105
|
"""Container for segmentation mask data and feature extraction.
|
|
100
106
|
|
|
101
107
|
Args:
|
|
102
|
-
mask_image: 2D integer array where each cell has a unique label (background=0).
|
|
103
|
-
intensity_image_dict: Optional dict mapping Channel
|
|
104
|
-
Each intensity array must have the same shape as mask_image.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
remove_edge_cells: Whether to remove cells touching image borders.
|
|
108
|
+
mask_image: 2D integer or boolean array where each cell has a unique label (background=0).
|
|
109
|
+
intensity_image_dict: Optional dict mapping Channel instances to 2D intensity arrays.
|
|
110
|
+
Each intensity array must have the same shape as mask_image. Channel names will be used
|
|
111
|
+
as suffixes for intensity properties. Example:
|
|
112
|
+
{DAPI: array, FITC: array}
|
|
113
|
+
remove_edge_cells: Whether to remove cells touching image borders. Defaults to True.
|
|
108
114
|
outline_extractor: Outline extraction method ("cellpose" or "skimage").
|
|
109
|
-
|
|
115
|
+
Defaults to "cellpose".
|
|
116
|
+
property_names: List of property names to compute. If None, uses
|
|
117
|
+
DEFAULT_CELL_PROPERTY_NAMES.
|
|
110
118
|
intensity_property_names: List of intensity property names to compute.
|
|
111
|
-
If None, uses
|
|
119
|
+
If None, uses DEFAULT_INTENSITY_PROPERTY_NAMES when intensity_image_dict is provided.
|
|
112
120
|
"""
|
|
113
121
|
|
|
114
|
-
mask_image:
|
|
115
|
-
intensity_image_dict:
|
|
122
|
+
mask_image: BoolArray | Int64Array
|
|
123
|
+
intensity_image_dict: Mapping[Channel, UInt16Array] | None = None
|
|
116
124
|
remove_edge_cells: bool = True
|
|
117
125
|
outline_extractor: OutlineExtractorMethod = "cellpose"
|
|
118
126
|
property_names: list[str] | None = field(default=None)
|
|
119
127
|
intensity_property_names: list[str] | None = field(default=None)
|
|
120
128
|
|
|
121
129
|
def __post_init__(self):
|
|
122
|
-
"""Validate inputs and
|
|
130
|
+
"""Validate inputs and set defaults."""
|
|
123
131
|
# Validate mask_image
|
|
124
132
|
if not isinstance(self.mask_image, np.ndarray):
|
|
125
133
|
raise TypeError("mask_image must be a numpy array")
|
|
@@ -130,10 +138,8 @@ class SegmentationMask:
|
|
|
130
138
|
|
|
131
139
|
# Validate intensity_image dict if provided
|
|
132
140
|
if self.intensity_image_dict is not None:
|
|
133
|
-
if not isinstance(self.intensity_image_dict,
|
|
134
|
-
raise TypeError(
|
|
135
|
-
"intensity_image_dict must be a dict mapping Channel enums to 2D arrays"
|
|
136
|
-
)
|
|
141
|
+
if not isinstance(self.intensity_image_dict, Mapping):
|
|
142
|
+
raise TypeError("intensity_image_dict must be a Mapping of channels to 2D arrays")
|
|
137
143
|
for channel, intensities in self.intensity_image_dict.items():
|
|
138
144
|
if not isinstance(intensities, np.ndarray):
|
|
139
145
|
raise TypeError(f"Intensity image for '{channel.name}' must be a numpy array")
|
|
@@ -155,32 +161,40 @@ class SegmentationMask:
|
|
|
155
161
|
else:
|
|
156
162
|
self.intensity_property_names = []
|
|
157
163
|
|
|
158
|
-
# Create mask processor
|
|
159
|
-
self._mask_processor = MaskProcessor(remove_edge_cells=self.remove_edge_cells)
|
|
160
|
-
|
|
161
|
-
# Create outline extractor
|
|
162
|
-
if self.outline_extractor == "cellpose":
|
|
163
|
-
self._outline_extractor = CellposeOutlineExtractor()
|
|
164
|
-
else: # Must be "skimage" due to Literal type
|
|
165
|
-
self._outline_extractor = SkimageOutlineExtractor()
|
|
166
|
-
|
|
167
164
|
@cached_property
|
|
168
165
|
def label_image(self) -> Int64Array:
|
|
169
|
-
"""Get processed label image with consecutive labels.
|
|
170
|
-
|
|
166
|
+
"""Get processed label image with consecutive labels.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
2D integer array with consecutive cell labels starting from 1 (background=0).
|
|
170
|
+
Edge cells removed if remove_edge_cells=True.
|
|
171
|
+
"""
|
|
172
|
+
return _process_mask(self.mask_image, self.remove_edge_cells)
|
|
171
173
|
|
|
172
174
|
@cached_property
|
|
173
175
|
def num_cells(self) -> int:
|
|
174
|
-
"""Get the number of cells in the mask.
|
|
176
|
+
"""Get the number of cells in the mask.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Integer count of cells (maximum label value in label_image).
|
|
180
|
+
"""
|
|
175
181
|
return int(self.label_image.max())
|
|
176
182
|
|
|
177
183
|
@cached_property
|
|
178
|
-
def cell_outlines(self) -> list[
|
|
179
|
-
"""Extract cell outlines using the configured outline extractor.
|
|
184
|
+
def cell_outlines(self) -> list[FloatArray]:
|
|
185
|
+
"""Extract cell outlines using the configured outline extractor.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
List of arrays, one per cell, containing outline coordinates in (y, x) format.
|
|
189
|
+
Returns empty list if no cells found.
|
|
190
|
+
"""
|
|
180
191
|
if self.num_cells == 0:
|
|
181
192
|
return []
|
|
182
193
|
|
|
183
|
-
|
|
194
|
+
if self.outline_extractor == "cellpose":
|
|
195
|
+
return _extract_outlines_cellpose(self.label_image)
|
|
196
|
+
else: # Must be "skimage" due to Literal type
|
|
197
|
+
return _extract_outlines_skimage(self.label_image)
|
|
184
198
|
|
|
185
199
|
@cached_property
|
|
186
200
|
def cell_properties(self) -> dict[str, ScalarArray]:
|
|
@@ -190,11 +204,12 @@ class SegmentationMask:
|
|
|
190
204
|
properties (mean, max, min intensity) for each channel if intensity images are provided.
|
|
191
205
|
|
|
192
206
|
For multichannel intensity images, property names are suffixed with the channel name:
|
|
193
|
-
-
|
|
194
|
-
-
|
|
207
|
+
- DAPI: "intensity_mean_DAPI"
|
|
208
|
+
- FITC: "intensity_mean_FITC"
|
|
195
209
|
|
|
196
210
|
Returns:
|
|
197
211
|
Dictionary mapping property names to arrays of values (one per cell).
|
|
212
|
+
Returns empty dict if no cells found.
|
|
198
213
|
"""
|
|
199
214
|
if self.num_cells == 0:
|
|
200
215
|
empty_props = (
|
|
@@ -210,10 +225,17 @@ class SegmentationMask:
|
|
|
210
225
|
return empty_props
|
|
211
226
|
|
|
212
227
|
# Extract morphological properties (no intensity image needed)
|
|
228
|
+
# Only compute extra properties if explicitly requested
|
|
229
|
+
extra_props = []
|
|
230
|
+
if self.property_names and "circularity" in self.property_names:
|
|
231
|
+
extra_props.append(circularity)
|
|
232
|
+
if self.property_names and "volume" in self.property_names:
|
|
233
|
+
extra_props.append(volume)
|
|
234
|
+
|
|
213
235
|
properties = ski.measure.regionprops_table(
|
|
214
236
|
self.label_image,
|
|
215
237
|
properties=self.property_names,
|
|
216
|
-
extra_properties=
|
|
238
|
+
extra_properties=extra_props,
|
|
217
239
|
)
|
|
218
240
|
|
|
219
241
|
# Extract intensity properties for each channel
|
|
@@ -231,19 +253,13 @@ class SegmentationMask:
|
|
|
231
253
|
return properties
|
|
232
254
|
|
|
233
255
|
@cached_property
|
|
234
|
-
def centroids_yx(self) ->
|
|
256
|
+
def centroids_yx(self) -> FloatArray:
|
|
235
257
|
"""Get cell centroids as (y, x) coordinates.
|
|
236
258
|
|
|
237
|
-
Extracts centroid coordinates from cell properties and returns them as a 2D array
|
|
238
|
-
where each row represents one cell's centroid in (y, x) format.
|
|
239
|
-
|
|
240
259
|
Returns:
|
|
241
260
|
Array of shape (num_cells, 2) with centroid coordinates.
|
|
242
261
|
Each row is [y_coordinate, x_coordinate] for one cell.
|
|
243
|
-
Returns empty array if "centroid"
|
|
244
|
-
|
|
245
|
-
Note:
|
|
246
|
-
If "centroid" is not in property_names, issues a warning and returns an empty array.
|
|
262
|
+
Returns empty (0, 2) array with warning if "centroid" not in property_names.
|
|
247
263
|
"""
|
|
248
264
|
if self.property_names and "centroid" not in self.property_names:
|
|
249
265
|
warnings.warn(
|
|
@@ -328,7 +344,7 @@ def circularity(region_mask: BoolArray) -> float:
|
|
|
328
344
|
Circularity value between 0 and 1. Returns 0 if perimeter is zero.
|
|
329
345
|
"""
|
|
330
346
|
# regionprops expects a labeled image, so convert the mask (0/1)
|
|
331
|
-
labeled_mask = region_mask.astype(np.int64
|
|
347
|
+
labeled_mask = region_mask.astype(np.int64)
|
|
332
348
|
|
|
333
349
|
# Compute standard region properties on this mask
|
|
334
350
|
props = ski.measure.regionprops(labeled_mask)[0]
|
|
@@ -356,7 +372,7 @@ def volume(region_mask: BoolArray) -> float:
|
|
|
356
372
|
Estimated volume in cubic pixels. Returns 0 if axis lengths cannot be computed.
|
|
357
373
|
"""
|
|
358
374
|
# regionprops expects a labeled image, so convert the mask (0/1)
|
|
359
|
-
labeled_mask = region_mask.astype(np.int64
|
|
375
|
+
labeled_mask = region_mask.astype(np.int64)
|
|
360
376
|
|
|
361
377
|
# Compute standard region properties on this mask
|
|
362
378
|
props = ski.measure.regionprops(labeled_mask)[0]
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from collections.abc import Iterator, Sequence
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Well:
|
|
12
|
+
"""Represents a single well in a microplate.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
id: Well identifier (e.g., "A01", "B12").
|
|
16
|
+
sample: Sample identifier or name in this well.
|
|
17
|
+
properties: Additional metadata or properties for this well.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
id: str
|
|
21
|
+
sample: str = ""
|
|
22
|
+
properties: dict[str, Any] = field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
def __post_init__(self):
|
|
25
|
+
"""Validate and normalize the well ID."""
|
|
26
|
+
if not self.id or len(self.id) < 2:
|
|
27
|
+
raise ValueError("Well ID must be at least 2 characters (e.g., 'A1' or 'A01')")
|
|
28
|
+
|
|
29
|
+
row = self.id[0].upper()
|
|
30
|
+
if not "A" <= row <= "Z":
|
|
31
|
+
raise ValueError(f"Row must be A-Z, got '{row}'")
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
column = int(self.id[1:])
|
|
35
|
+
except ValueError as e:
|
|
36
|
+
raise ValueError(f"Could not parse column number from '{self.id}'") from e
|
|
37
|
+
|
|
38
|
+
# Support up to 48 columns
|
|
39
|
+
if not 1 <= column <= 48:
|
|
40
|
+
raise ValueError(f"Column must be 1-48, got {column}")
|
|
41
|
+
|
|
42
|
+
# Normalize to capital letter, zero-padded format (a1 -> A01)
|
|
43
|
+
normalized = f"{row}{column:02d}"
|
|
44
|
+
if normalized != self.id:
|
|
45
|
+
object.__setattr__(self, "id", normalized)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def row(self) -> str:
|
|
49
|
+
"""Extract row letter from well ID."""
|
|
50
|
+
return self.id[0]
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def column(self) -> int:
|
|
54
|
+
"""Extract column number from well ID."""
|
|
55
|
+
return int(self.id[1:])
|
|
56
|
+
|
|
57
|
+
def __str__(self) -> str:
|
|
58
|
+
"""Return well ID string."""
|
|
59
|
+
return self.id
|
|
60
|
+
|
|
61
|
+
def __repr__(self) -> str:
|
|
62
|
+
"""Return a string that could be used to recreate this object."""
|
|
63
|
+
props = f", properties={self.properties!r}" if self.properties else ""
|
|
64
|
+
return f"Well(id='{self.id}', sample='{self.sample}'{props})"
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_dict(cls, data: dict[str, Any]) -> Well:
|
|
68
|
+
"""Create a Well from a dictionary (e.g., from CSV row).
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
data: Dictionary containing 'well_id' key and optional 'sample' and property keys.
|
|
72
|
+
CSV files should have a 'well_id' column.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Well instance created from the dictionary.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
ValueError: If 'well_id' key is missing from the dictionary or is not a string.
|
|
79
|
+
"""
|
|
80
|
+
if "well_id" not in data:
|
|
81
|
+
raise ValueError("Dictionary must contain 'well_id' key")
|
|
82
|
+
|
|
83
|
+
well_id = data["well_id"]
|
|
84
|
+
if not isinstance(well_id, str):
|
|
85
|
+
raise ValueError(f"well_id must be a string, got {type(well_id).__name__}")
|
|
86
|
+
|
|
87
|
+
sample = data.get("sample", "")
|
|
88
|
+
properties = {k: v for k, v in data.items() if k not in ("well_id", "sample")}
|
|
89
|
+
|
|
90
|
+
return cls(well_id, sample, properties)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class MicroplateLayout:
|
|
95
|
+
"""Representation of a microwell plate layout.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
wells: Sequence of Well objects (converted to dict internally for efficient lookup).
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
wells: Sequence[Well]
|
|
102
|
+
_layout: dict[str, Well] = field(init=False, repr=False)
|
|
103
|
+
|
|
104
|
+
def __post_init__(self):
|
|
105
|
+
"""Build internal dict from wells and validate for duplicates."""
|
|
106
|
+
well_dict = {}
|
|
107
|
+
for well in self.wells:
|
|
108
|
+
if well.id in well_dict:
|
|
109
|
+
raise ValueError(f"Duplicate well ID: '{well.id}'")
|
|
110
|
+
well_dict[well.id] = well
|
|
111
|
+
|
|
112
|
+
# Store as dict internally for efficient lookup
|
|
113
|
+
object.__setattr__(self, "_layout", well_dict)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def layout(self) -> dict[str, Well]:
|
|
117
|
+
"""Return the mapping of well IDs to Well objects."""
|
|
118
|
+
return self._layout
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def rows(self) -> list[str]:
|
|
122
|
+
"""Unique rows in the plate layout."""
|
|
123
|
+
return sorted({well.row for well in self.layout.values()})
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def columns(self) -> list[int]:
|
|
127
|
+
"""Unique columns in the plate layout."""
|
|
128
|
+
return sorted({well.column for well in self.layout.values()})
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def well_ids(self) -> list[str]:
|
|
132
|
+
"""Return a list of all well IDs in the layout."""
|
|
133
|
+
return sorted(self.layout.keys())
|
|
134
|
+
|
|
135
|
+
def __getitem__(self, well_id: str) -> Well:
|
|
136
|
+
"""Get a well by its ID.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
well_id: The well ID to retrieve (e.g., "A01", "A1", "H12")
|
|
140
|
+
Non-normalized IDs (e.g., "A1") are automatically normalized.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
The Well object corresponding to the given ID
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
KeyError: If the well ID doesn't exist in the layout
|
|
147
|
+
"""
|
|
148
|
+
# Normalize the well_id before lookup to support both "A1" and "A01" formats
|
|
149
|
+
try:
|
|
150
|
+
normalized = Well(well_id).id
|
|
151
|
+
except ValueError as e:
|
|
152
|
+
raise KeyError(f"Invalid well ID '{well_id}': {e}") from None
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
return self.layout[normalized]
|
|
156
|
+
except KeyError:
|
|
157
|
+
raise KeyError(f"Well ID '{well_id}' not found in plate layout.") from None
|
|
158
|
+
|
|
159
|
+
def __len__(self) -> int:
|
|
160
|
+
"""Return the number of wells in the layout."""
|
|
161
|
+
return len(self.layout)
|
|
162
|
+
|
|
163
|
+
def __contains__(self, well_id: str) -> bool:
|
|
164
|
+
"""Check if a well ID exists in the layout.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
well_id: The well ID to check (e.g., "A01", "A1", "H12")
|
|
168
|
+
Non-normalized IDs (e.g., "A1") are automatically normalized.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
True if the well exists, False otherwise
|
|
172
|
+
"""
|
|
173
|
+
# Normalize the well_id before checking to support both "A1" and "A01" formats
|
|
174
|
+
try:
|
|
175
|
+
normalized = Well(well_id).id
|
|
176
|
+
return normalized in self.layout
|
|
177
|
+
except ValueError:
|
|
178
|
+
# Invalid well ID format
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
def __iter__(self) -> Iterator[Well]:
|
|
182
|
+
"""Iterate over wells in the layout."""
|
|
183
|
+
return iter(self.layout.values())
|
|
184
|
+
|
|
185
|
+
@classmethod
|
|
186
|
+
def from_csv(cls, csv_path: Path, **kwargs) -> MicroplateLayout:
|
|
187
|
+
"""Load a microplate layout from a CSV file using pandas.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
csv_path: Path to CSV file containing well_id, sample, and optional property columns.
|
|
191
|
+
**kwargs: Additional arguments passed to pd.read_csv (e.g., encoding, dtype).
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
MicroplateLayout instance with wells parsed from the CSV.
|
|
195
|
+
|
|
196
|
+
Raises:
|
|
197
|
+
ValueError: If CSV is empty or missing required 'well_id' column.
|
|
198
|
+
"""
|
|
199
|
+
df = pd.read_csv(csv_path, **kwargs)
|
|
200
|
+
|
|
201
|
+
if df.empty:
|
|
202
|
+
raise ValueError(f"CSV file '{csv_path}' is empty")
|
|
203
|
+
|
|
204
|
+
if "well_id" not in df.columns:
|
|
205
|
+
raise ValueError(
|
|
206
|
+
f"CSV file '{csv_path}' missing required 'well_id' column. "
|
|
207
|
+
f"Found columns: {list(df.columns)}"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
wells = [Well.from_dict(row) for row in df.to_dict("records")]
|
|
211
|
+
|
|
212
|
+
return cls(wells)
|
|
213
|
+
|
|
214
|
+
def to_dataframe(self) -> pd.DataFrame:
|
|
215
|
+
"""Convert plate layout to a pandas DataFrame with all well data.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
DataFrame with columns: well_id, row, column, sample, and any additional properties.
|
|
219
|
+
One row per well in the layout.
|
|
220
|
+
"""
|
|
221
|
+
if not self.layout:
|
|
222
|
+
return pd.DataFrame()
|
|
223
|
+
|
|
224
|
+
data = []
|
|
225
|
+
for well in self.layout.values():
|
|
226
|
+
row_data = {
|
|
227
|
+
"well_id": well.id,
|
|
228
|
+
"row": well.row,
|
|
229
|
+
"column": well.column,
|
|
230
|
+
"sample": well.sample,
|
|
231
|
+
}
|
|
232
|
+
# Add any additional properties
|
|
233
|
+
row_data.update(well.properties)
|
|
234
|
+
data.append(row_data)
|
|
235
|
+
|
|
236
|
+
return pd.DataFrame(data)
|
|
237
|
+
|
|
238
|
+
def display(self) -> str:
|
|
239
|
+
"""Display the plate layout as a formatted grid table.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
String representation of the plate as a pivot table with rows and columns.
|
|
243
|
+
"""
|
|
244
|
+
df = self.to_dataframe()
|
|
245
|
+
if df.empty:
|
|
246
|
+
return "Empty plate layout"
|
|
247
|
+
|
|
248
|
+
# Create pivot table: rows as index, columns as columns, sample as values
|
|
249
|
+
pivot = df.pivot(index="row", columns="column", values="sample")
|
|
250
|
+
pivot = pivot.fillna("-")
|
|
251
|
+
return pivot.to_string()
|