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.
Files changed (52) hide show
  1. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/CHANGELOG.md +24 -0
  2. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/PKG-INFO +4 -4
  3. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/pyproject.toml +4 -4
  4. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/masks.py +107 -91
  5. arcadia_microscopy_tools-0.2.3/src/arcadia_microscopy_tools/microplate.py +251 -0
  6. arcadia_microscopy_tools-0.2.3/src/arcadia_microscopy_tools/tests/test_microplate.py +55 -0
  7. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/uv.lock +128 -117
  8. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  9. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/.github/workflows/lint.yml +0 -0
  10. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/.github/workflows/publish.yml +0 -0
  11. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/.github/workflows/test.yml +0 -0
  12. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/.gitignore +0 -0
  13. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/.pre-commit-config.yaml +0 -0
  14. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/LICENSE +0 -0
  15. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/Makefile +0 -0
  16. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/README.md +0 -0
  17. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/docs/.gitignore +0 -0
  18. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/docs/Makefile +0 -0
  19. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/docs/_assets/logo.png +0 -0
  20. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/docs/_static/css/label.css +0 -0
  21. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/docs/conf.py +0 -0
  22. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/docs/examples/README.md +0 -0
  23. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/docs/examples/basic_usage.ipynb +0 -0
  24. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/docs/examples/cell_segmentation.ipynb +0 -0
  25. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/docs/examples/fluorescence_overlays.ipynb +0 -0
  26. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/docs/examples/index.md +0 -0
  27. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/docs/index.md +0 -0
  28. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/docs/install.md +0 -0
  29. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/docs/license/index.md +0 -0
  30. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/__init__.py +0 -0
  31. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/blending.py +0 -0
  32. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/channels.py +0 -0
  33. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/metadata_structures.py +0 -0
  34. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/microscopy.py +0 -0
  35. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/model.py +0 -0
  36. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/nikon.py +0 -0
  37. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/operations.py +0 -0
  38. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/pipeline.py +0 -0
  39. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/tests/__init__.py +0 -0
  40. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/tests/conftest.py +0 -0
  41. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/tests/data/README.md +0 -0
  42. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/tests/data/example-cerevisiae.nd2 +0 -0
  43. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/tests/data/example-multichannel.nd2 +0 -0
  44. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/tests/data/example-pbmc.nd2 +0 -0
  45. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/tests/data/example-timelapse.nd2 +0 -0
  46. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/tests/data/example-zstack.nd2 +0 -0
  47. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/tests/data/known-metadata.yml +0 -0
  48. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/tests/test_channels.py +0 -0
  49. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/tests/test_microscopy.py +0 -0
  50. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/tests/test_model.py +0 -0
  51. {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.3}/src/arcadia_microscopy_tools/typing.py +0 -0
  52. {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.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.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
- class SkimageOutlineExtractor:
49
- """Extract cell outlines using scikit-image's find_contours."""
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
- def extract_outlines(self, label_image: Int64Array) -> list[ScalarArray]:
52
- """Extract outlines from label image."""
53
- # Get unique cell IDs (excluding background)
54
- unique_labels = np.unique(label_image)
55
- unique_labels = unique_labels[unique_labels > 0]
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
- outlines = []
58
- for cell_id in unique_labels:
59
- cell_mask = (label_image == cell_id).astype(np.uint8)
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
- @dataclass
70
- class MaskProcessor:
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
- remove_edge_cells: Whether to remove cells touching image borders.
75
- """
66
+ label_image: 2D integer array where each cell has a unique label.
76
67
 
77
- remove_edge_cells: bool = True
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
- Args:
83
- mask_image: Input mask array where each cell has a unique label.
74
+ def _extract_outlines_skimage(label_image: Int64Array) -> list[FloatArray]:
75
+ """Extract cell outlines using scikit-image's find_contours.
84
76
 
85
- Returns:
86
- Processed label image with consecutive labels starting from 1.
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
- # Ensure consecutive labels
93
- _label_image = ski.measure.label(_label_image).astype(np.int64) # type: ignore
94
- return _label_image
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 enums to 2D intensity arrays.
104
- Each intensity array must have the same shape as mask_image.
105
- Channel names will be used as suffixes for intensity properties.
106
- Example: {Channel.DAPI: [M x N], Channel.FITC: [M x N]}
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
- property_names: List of property names to compute. If None, uses default property names.
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 default intensity properties when intensity_image_dict is provided.
119
+ If None, uses DEFAULT_INTENSITY_PROPERTY_NAMES when intensity_image_dict is provided.
112
120
  """
113
121
 
114
- mask_image: ScalarArray
115
- intensity_image_dict: dict[Channel, ScalarArray] | None = None
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 create processors."""
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, 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
- return self._mask_processor.process_mask(self.mask_image)
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[ScalarArray]:
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
- return self._outline_extractor.extract_outlines(self.label_image)
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
- - Channel.DAPI: "intensity_mean_DAPI"
194
- - Channel.FITC: "intensity_mean_FITC"
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=[circularity, volume],
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) -> ScalarArray:
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" is not included in property_names.
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, copy=False)
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, copy=False)
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()