arcadia-microscopy-tools 0.2.2__tar.gz → 0.2.4__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.4}/CHANGELOG.md +35 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/PKG-INFO +4 -4
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/pyproject.toml +4 -4
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/src/arcadia_microscopy_tools/blending.py +13 -13
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/src/arcadia_microscopy_tools/masks.py +107 -91
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/src/arcadia_microscopy_tools/metadata_structures.py +3 -3
- arcadia_microscopy_tools-0.2.4/src/arcadia_microscopy_tools/microplate.py +251 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/src/arcadia_microscopy_tools/model.py +3 -3
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/src/arcadia_microscopy_tools/nikon.py +2 -2
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/src/arcadia_microscopy_tools/operations.py +95 -15
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/src/arcadia_microscopy_tools/pipeline.py +30 -9
- arcadia_microscopy_tools-0.2.4/src/arcadia_microscopy_tools/tests/test_microplate.py +55 -0
- arcadia_microscopy_tools-0.2.4/src/arcadia_microscopy_tools/tests/test_pipeline.py +278 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/src/arcadia_microscopy_tools/typing.py +5 -4
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/uv.lock +128 -117
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/.github/workflows/lint.yml +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/.github/workflows/publish.yml +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/.github/workflows/test.yml +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/.gitignore +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/.pre-commit-config.yaml +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/LICENSE +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/Makefile +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/README.md +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/docs/.gitignore +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/docs/Makefile +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/docs/_assets/logo.png +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/docs/_static/css/label.css +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/docs/conf.py +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/docs/examples/README.md +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/docs/examples/basic_usage.ipynb +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/docs/examples/cell_segmentation.ipynb +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/docs/examples/fluorescence_overlays.ipynb +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/docs/examples/index.md +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/docs/index.md +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/docs/install.md +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/docs/license/index.md +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/src/arcadia_microscopy_tools/__init__.py +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/src/arcadia_microscopy_tools/channels.py +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/src/arcadia_microscopy_tools/microscopy.py +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/src/arcadia_microscopy_tools/tests/__init__.py +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/src/arcadia_microscopy_tools/tests/conftest.py +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/src/arcadia_microscopy_tools/tests/data/README.md +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/src/arcadia_microscopy_tools/tests/data/example-cerevisiae.nd2 +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/src/arcadia_microscopy_tools/tests/data/example-multichannel.nd2 +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/src/arcadia_microscopy_tools/tests/data/example-pbmc.nd2 +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/src/arcadia_microscopy_tools/tests/data/example-timelapse.nd2 +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/src/arcadia_microscopy_tools/tests/data/example-zstack.nd2 +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/src/arcadia_microscopy_tools/tests/data/known-metadata.yml +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/src/arcadia_microscopy_tools/tests/test_channels.py +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/src/arcadia_microscopy_tools/tests/test_microscopy.py +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/src/arcadia_microscopy_tools/tests/test_model.py +0 -0
- {arcadia_microscopy_tools-0.2.2 → arcadia_microscopy_tools-0.2.4}/src/arcadia_microscopy_tools/utils.py +0 -0
|
@@ -5,6 +5,41 @@ 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.4] - 2026-01-30
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- New `apply_threshold()` image operation for binarizing images
|
|
12
|
+
- `preserve_dtype` parameter to Pipeline classes for more flexible control of managing data types during processing
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- Refactored typing module:
|
|
16
|
+
- Added `UbyteArray` as a type of `ScalarArray`
|
|
17
|
+
- Renamed `FloatArray` to `Float64Array` for clarity
|
|
18
|
+
|
|
19
|
+
## [0.2.3] - 2026-01-22
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- New `microplate.py` module for managing multiwell plate layouts:
|
|
23
|
+
- `Well` class: Represents individual wells with ID normalization (e.g., "a1" → "A01"), sample tracking, and custom properties
|
|
24
|
+
- `MicroplateLayout` class: Manages complete plate layouts with features:
|
|
25
|
+
- Load layouts from CSV files with `from_csv()`
|
|
26
|
+
- Display plate layouts as formatted grid tables with `display()`
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- Refactored `masks.py` architecture for improved performance and maintainability:
|
|
30
|
+
- Refactored `MaskProcessor` class into standalone `_process_mask()` function
|
|
31
|
+
- Updated `DEFAULT_CELL_PROPERTY_NAMES` to include circularity and volume by default
|
|
32
|
+
- Made cellpose and modal optional dependencies to reduce installation size:
|
|
33
|
+
- Install with `uv pip install arcadia-microscopy-tools[segmentation]` for cellpose support
|
|
34
|
+
- Install with `uv pip install arcadia-microscopy-tools[all]` for all optional dependencies
|
|
35
|
+
- Moved pytest from main dependencies to dev group
|
|
36
|
+
- Consolidated all dev tools into single `dev` dependency group
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
- Coordinate format inconsistency in outline extractors: both cellpose and skimage now return outlines in (y, x) format
|
|
40
|
+
- `isinstance` check in `SegmentationMask` now accepts `Mapping` type instead of just `dict` to match type annotation
|
|
41
|
+
- Empty outline arrays now properly shaped as (0, 2) for consistency
|
|
42
|
+
|
|
8
43
|
## [0.2.2] - 2026-01-08
|
|
9
44
|
|
|
10
45
|
### 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.4
|
|
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.4"
|
|
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",
|
|
@@ -7,7 +7,7 @@ from matplotlib.colors import LinearSegmentedColormap, Normalize
|
|
|
7
7
|
from skimage.color import gray2rgb
|
|
8
8
|
|
|
9
9
|
from .channels import Channel
|
|
10
|
-
from .typing import
|
|
10
|
+
from .typing import Float64Array
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
@dataclass
|
|
@@ -23,7 +23,7 @@ class Layer:
|
|
|
23
23
|
"""
|
|
24
24
|
|
|
25
25
|
channel: Channel
|
|
26
|
-
intensities:
|
|
26
|
+
intensities: Float64Array
|
|
27
27
|
opacity: float = 1.0
|
|
28
28
|
transparent: bool = True
|
|
29
29
|
|
|
@@ -34,11 +34,11 @@ class Layer:
|
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
def overlay_channels(
|
|
37
|
-
background:
|
|
38
|
-
channel_intensities: dict[Channel,
|
|
37
|
+
background: Float64Array,
|
|
38
|
+
channel_intensities: dict[Channel, Float64Array],
|
|
39
39
|
opacity: float = 1.0,
|
|
40
40
|
transparent: bool = True,
|
|
41
|
-
) ->
|
|
41
|
+
) -> Float64Array:
|
|
42
42
|
"""Create a fluorescence overlay.
|
|
43
43
|
|
|
44
44
|
All channels are blended with the same opacity and transparency settings.
|
|
@@ -73,9 +73,9 @@ def overlay_channels(
|
|
|
73
73
|
|
|
74
74
|
|
|
75
75
|
def create_sequential_overlay(
|
|
76
|
-
background:
|
|
76
|
+
background: Float64Array,
|
|
77
77
|
layers: list[Layer],
|
|
78
|
-
) ->
|
|
78
|
+
) -> Float64Array:
|
|
79
79
|
"""Create an overlay by sequentially blending multiple channels onto a background.
|
|
80
80
|
|
|
81
81
|
Args:
|
|
@@ -109,10 +109,10 @@ def create_sequential_overlay(
|
|
|
109
109
|
|
|
110
110
|
|
|
111
111
|
def alpha_blend(
|
|
112
|
-
background:
|
|
113
|
-
foreground:
|
|
114
|
-
alpha:
|
|
115
|
-
) ->
|
|
112
|
+
background: Float64Array,
|
|
113
|
+
foreground: Float64Array,
|
|
114
|
+
alpha: Float64Array,
|
|
115
|
+
) -> Float64Array:
|
|
116
116
|
"""Alpha blend foreground onto background.
|
|
117
117
|
|
|
118
118
|
Args:
|
|
@@ -127,9 +127,9 @@ def alpha_blend(
|
|
|
127
127
|
|
|
128
128
|
|
|
129
129
|
def colorize(
|
|
130
|
-
intensities:
|
|
130
|
+
intensities: Float64Array,
|
|
131
131
|
colormap: LinearSegmentedColormap,
|
|
132
|
-
) ->
|
|
132
|
+
) -> Float64Array:
|
|
133
133
|
"""Apply a colormap to a 2D intensity array.
|
|
134
134
|
|
|
135
135
|
Args:
|
|
@@ -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, Float64Array, 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[Float64Array]:
|
|
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[Float64Array]:
|
|
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[Float64Array]:
|
|
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) -> Float64Array:
|
|
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]
|
|
@@ -5,7 +5,7 @@ from enum import Flag, auto
|
|
|
5
5
|
from typing import TYPE_CHECKING
|
|
6
6
|
|
|
7
7
|
from .channels import Channel
|
|
8
|
-
from .typing import
|
|
8
|
+
from .typing import Float64Array
|
|
9
9
|
|
|
10
10
|
if TYPE_CHECKING:
|
|
11
11
|
from dataclasses import Field
|
|
@@ -86,8 +86,8 @@ class AcquisitionSettings(DimensionValidatorMixin):
|
|
|
86
86
|
exposure_time_ms: float
|
|
87
87
|
zoom: float | None = None
|
|
88
88
|
binning: str | None = None
|
|
89
|
-
frame_intervals_ms:
|
|
90
|
-
wavelengths_nm:
|
|
89
|
+
frame_intervals_ms: Float64Array | None = dimension_field(DimensionFlags.TIMELAPSE)
|
|
90
|
+
wavelengths_nm: Float64Array | None = dimension_field(DimensionFlags.SPECTRAL)
|
|
91
91
|
|
|
92
92
|
|
|
93
93
|
@dataclass
|