arcadia-microscopy-tools 0.2.3__py3-none-any.whl → 0.2.5__py3-none-any.whl
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/__init__.py +1 -1
- arcadia_microscopy_tools/blending.py +13 -13
- arcadia_microscopy_tools/masks.py +56 -54
- arcadia_microscopy_tools/metadata_structures.py +3 -3
- arcadia_microscopy_tools/model.py +3 -3
- arcadia_microscopy_tools/nikon.py +2 -2
- arcadia_microscopy_tools/operations.py +95 -15
- arcadia_microscopy_tools/pipeline.py +30 -9
- arcadia_microscopy_tools/tests/test_pipeline.py +278 -0
- arcadia_microscopy_tools/typing.py +5 -4
- {arcadia_microscopy_tools-0.2.3.dist-info → arcadia_microscopy_tools-0.2.5.dist-info}/METADATA +1 -1
- {arcadia_microscopy_tools-0.2.3.dist-info → arcadia_microscopy_tools-0.2.5.dist-info}/RECORD +14 -13
- {arcadia_microscopy_tools-0.2.3.dist-info → arcadia_microscopy_tools-0.2.5.dist-info}/WHEEL +0 -0
- {arcadia_microscopy_tools-0.2.3.dist-info → arcadia_microscopy_tools-0.2.5.dist-info}/licenses/LICENSE +0 -0
|
@@ -3,7 +3,7 @@ from arcadia_microscopy_tools.channels import Channel
|
|
|
3
3
|
from arcadia_microscopy_tools.microscopy import MicroscopyImage
|
|
4
4
|
from arcadia_microscopy_tools.pipeline import ImageOperation, Pipeline, PipelineParallelized
|
|
5
5
|
|
|
6
|
-
__version__ = "0.2.
|
|
6
|
+
__version__ = "0.2.5"
|
|
7
7
|
|
|
8
8
|
__all__ = [
|
|
9
9
|
"Channel",
|
|
@@ -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:
|
|
@@ -10,7 +10,7 @@ import skimage as ski
|
|
|
10
10
|
from cellpose.utils import outlines_list
|
|
11
11
|
|
|
12
12
|
from .channels import Channel
|
|
13
|
-
from .typing import BoolArray,
|
|
13
|
+
from .typing import BoolArray, Float64Array, Int64Array, ScalarArray, UInt16Array
|
|
14
14
|
|
|
15
15
|
DEFAULT_CELL_PROPERTY_NAMES = [
|
|
16
16
|
"label",
|
|
@@ -34,8 +34,6 @@ DEFAULT_INTENSITY_PROPERTY_NAMES = [
|
|
|
34
34
|
"intensity_std",
|
|
35
35
|
]
|
|
36
36
|
|
|
37
|
-
OutlineExtractorMethod = Literal["cellpose", "skimage"]
|
|
38
|
-
|
|
39
37
|
|
|
40
38
|
def _process_mask(
|
|
41
39
|
mask_image: BoolArray | Int64Array,
|
|
@@ -59,7 +57,7 @@ def _process_mask(
|
|
|
59
57
|
return label_image
|
|
60
58
|
|
|
61
59
|
|
|
62
|
-
def _extract_outlines_cellpose(label_image: Int64Array) -> list[
|
|
60
|
+
def _extract_outlines_cellpose(label_image: Int64Array) -> list[Float64Array]:
|
|
63
61
|
"""Extract cell outlines using Cellpose's outlines_list function.
|
|
64
62
|
|
|
65
63
|
Args:
|
|
@@ -68,10 +66,12 @@ def _extract_outlines_cellpose(label_image: Int64Array) -> list[FloatArray]:
|
|
|
68
66
|
Returns:
|
|
69
67
|
List of arrays, one per cell, containing outline coordinates in (y, x) format.
|
|
70
68
|
"""
|
|
71
|
-
|
|
69
|
+
outlines = outlines_list(label_image, multiprocessing=False)
|
|
70
|
+
# Cellpose returns (x, y) coordinates, flip to (y, x) to match standard (row, col) format
|
|
71
|
+
return [outline[:, [1, 0]] if len(outline) > 0 else outline for outline in outlines]
|
|
72
72
|
|
|
73
73
|
|
|
74
|
-
def _extract_outlines_skimage(label_image: Int64Array) -> list[
|
|
74
|
+
def _extract_outlines_skimage(label_image: Int64Array) -> list[Float64Array]:
|
|
75
75
|
"""Extract cell outlines using scikit-image's find_contours.
|
|
76
76
|
|
|
77
77
|
Args:
|
|
@@ -91,8 +91,6 @@ def _extract_outlines_skimage(label_image: Int64Array) -> list[FloatArray]:
|
|
|
91
91
|
contours = ski.measure.find_contours(cell_mask, level=0.5)
|
|
92
92
|
if contours:
|
|
93
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
94
|
outlines.append(main_contour)
|
|
97
95
|
else:
|
|
98
96
|
# Include empty array to maintain alignment with cell labels
|
|
@@ -112,7 +110,8 @@ class SegmentationMask:
|
|
|
112
110
|
{DAPI: array, FITC: array}
|
|
113
111
|
remove_edge_cells: Whether to remove cells touching image borders. Defaults to True.
|
|
114
112
|
outline_extractor: Outline extraction method ("cellpose" or "skimage").
|
|
115
|
-
Defaults to "cellpose".
|
|
113
|
+
Defaults to "cellpose". In practice, cellpose is ~2x faster but skimage handles
|
|
114
|
+
vertically oriented cell outlines better.
|
|
116
115
|
property_names: List of property names to compute. If None, uses
|
|
117
116
|
DEFAULT_CELL_PROPERTY_NAMES.
|
|
118
117
|
intensity_property_names: List of intensity property names to compute.
|
|
@@ -122,7 +121,7 @@ class SegmentationMask:
|
|
|
122
121
|
mask_image: BoolArray | Int64Array
|
|
123
122
|
intensity_image_dict: Mapping[Channel, UInt16Array] | None = None
|
|
124
123
|
remove_edge_cells: bool = True
|
|
125
|
-
outline_extractor:
|
|
124
|
+
outline_extractor: Literal["cellpose", "skimage"] = "cellpose"
|
|
126
125
|
property_names: list[str] | None = field(default=None)
|
|
127
126
|
intensity_property_names: list[str] | None = field(default=None)
|
|
128
127
|
|
|
@@ -133,8 +132,10 @@ class SegmentationMask:
|
|
|
133
132
|
raise TypeError("mask_image must be a numpy array")
|
|
134
133
|
if self.mask_image.ndim != 2:
|
|
135
134
|
raise ValueError("mask_image must be a 2D array")
|
|
136
|
-
if self.mask_image
|
|
135
|
+
if np.any(self.mask_image < 0):
|
|
137
136
|
raise ValueError("mask_image must have non-negative values")
|
|
137
|
+
if self.mask_image.max() == 0:
|
|
138
|
+
raise ValueError("mask_image contains no cells (all values are 0)")
|
|
138
139
|
|
|
139
140
|
# Validate intensity_image dict if provided
|
|
140
141
|
if self.intensity_image_dict is not None:
|
|
@@ -181,19 +182,25 @@ class SegmentationMask:
|
|
|
181
182
|
return int(self.label_image.max())
|
|
182
183
|
|
|
183
184
|
@cached_property
|
|
184
|
-
def cell_outlines(self) -> list[
|
|
185
|
+
def cell_outlines(self) -> list[Float64Array]:
|
|
185
186
|
"""Extract cell outlines using the configured outline extractor.
|
|
186
187
|
|
|
187
188
|
Returns:
|
|
188
189
|
List of arrays, one per cell, containing outline coordinates in (y, x) format.
|
|
189
|
-
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
ValueError: If no cells are found in the mask.
|
|
193
|
+
|
|
194
|
+
Note:
|
|
195
|
+
The cellpose method is ~2x faster in general but skimage handles
|
|
196
|
+
vertically oriented cells/outlines better.
|
|
190
197
|
"""
|
|
191
198
|
if self.num_cells == 0:
|
|
192
|
-
|
|
199
|
+
raise ValueError("No cells found in mask. Cannot extract cell outlines.")
|
|
193
200
|
|
|
194
201
|
if self.outline_extractor == "cellpose":
|
|
195
202
|
return _extract_outlines_cellpose(self.label_image)
|
|
196
|
-
else: #
|
|
203
|
+
else: # must be "skimage" due to Literal type
|
|
197
204
|
return _extract_outlines_skimage(self.label_image)
|
|
198
205
|
|
|
199
206
|
@cached_property
|
|
@@ -205,24 +212,16 @@ class SegmentationMask:
|
|
|
205
212
|
|
|
206
213
|
For multichannel intensity images, property names are suffixed with the channel name:
|
|
207
214
|
- DAPI: "intensity_mean_DAPI"
|
|
208
|
-
- FITC: "
|
|
215
|
+
- FITC: "intensity_max_FITC"
|
|
209
216
|
|
|
210
217
|
Returns:
|
|
211
218
|
Dictionary mapping property names to arrays of values (one per cell).
|
|
212
|
-
|
|
219
|
+
|
|
220
|
+
Raises:
|
|
221
|
+
ValueError: If no cells are found in the mask.
|
|
213
222
|
"""
|
|
214
223
|
if self.num_cells == 0:
|
|
215
|
-
|
|
216
|
-
{property_name: np.array([]) for property_name in self.property_names}
|
|
217
|
-
if self.property_names
|
|
218
|
-
else {}
|
|
219
|
-
)
|
|
220
|
-
# Add empty intensity properties if requested
|
|
221
|
-
if self.intensity_image_dict and self.intensity_property_names:
|
|
222
|
-
for channel in self.intensity_image_dict:
|
|
223
|
-
for prop_name in self.intensity_property_names:
|
|
224
|
-
empty_props[f"{prop_name}_{channel.name}"] = np.array([])
|
|
225
|
-
return empty_props
|
|
224
|
+
raise ValueError("No cells found in mask. Cannot extract cell properties.")
|
|
226
225
|
|
|
227
226
|
# Extract morphological properties (no intensity image needed)
|
|
228
227
|
# Only compute extra properties if explicitly requested
|
|
@@ -232,12 +231,18 @@ class SegmentationMask:
|
|
|
232
231
|
if self.property_names and "volume" in self.property_names:
|
|
233
232
|
extra_props.append(volume)
|
|
234
233
|
|
|
234
|
+
# Compute cell properties
|
|
235
235
|
properties = ski.measure.regionprops_table(
|
|
236
236
|
self.label_image,
|
|
237
237
|
properties=self.property_names,
|
|
238
238
|
extra_properties=extra_props,
|
|
239
239
|
)
|
|
240
240
|
|
|
241
|
+
if "centroid-0" in properties:
|
|
242
|
+
properties["centroid_y"] = properties.pop("centroid-0")
|
|
243
|
+
if "centroid-1" in properties:
|
|
244
|
+
properties["centroid_x"] = properties.pop("centroid-1")
|
|
245
|
+
|
|
241
246
|
# Extract intensity properties for each channel
|
|
242
247
|
if self.intensity_image_dict and self.intensity_property_names:
|
|
243
248
|
for channel, intensities in self.intensity_image_dict.items():
|
|
@@ -248,18 +253,21 @@ class SegmentationMask:
|
|
|
248
253
|
)
|
|
249
254
|
# Add channel suffix to property names
|
|
250
255
|
for prop_name, prop_values in channel_props.items():
|
|
251
|
-
properties[f"{prop_name}_{channel.name}"] = prop_values
|
|
256
|
+
properties[f"{prop_name}_{channel.name.lower()}"] = prop_values
|
|
252
257
|
|
|
253
258
|
return properties
|
|
254
259
|
|
|
255
260
|
@cached_property
|
|
256
|
-
def centroids_yx(self) ->
|
|
261
|
+
def centroids_yx(self) -> Float64Array:
|
|
257
262
|
"""Get cell centroids as (y, x) coordinates.
|
|
258
263
|
|
|
259
264
|
Returns:
|
|
260
265
|
Array of shape (num_cells, 2) with centroid coordinates.
|
|
261
266
|
Each row is [y_coordinate, x_coordinate] for one cell.
|
|
262
267
|
Returns empty (0, 2) array with warning if "centroid" not in property_names.
|
|
268
|
+
|
|
269
|
+
Raises:
|
|
270
|
+
ValueError: If no cells are found in the mask.
|
|
263
271
|
"""
|
|
264
272
|
if self.property_names and "centroid" not in self.property_names:
|
|
265
273
|
warnings.warn(
|
|
@@ -270,8 +278,8 @@ class SegmentationMask:
|
|
|
270
278
|
)
|
|
271
279
|
return np.array([]).reshape(0, 2)
|
|
272
280
|
|
|
273
|
-
yc = self.cell_properties["
|
|
274
|
-
xc = self.cell_properties["
|
|
281
|
+
yc = self.cell_properties["centroid_y"]
|
|
282
|
+
xc = self.cell_properties["centroid_x"]
|
|
275
283
|
return np.array([yc, xc], dtype=float).T
|
|
276
284
|
|
|
277
285
|
def convert_properties_to_microns(
|
|
@@ -281,34 +289,28 @@ class SegmentationMask:
|
|
|
281
289
|
"""Convert cell properties from pixels to microns.
|
|
282
290
|
|
|
283
291
|
Applies appropriate scaling factors based on the dimensionality of each property:
|
|
284
|
-
- Linear measurements (1D): multiplied by pixel_size_um
|
|
285
|
-
- Area measurements (2D): multiplied by pixel_size_um
|
|
286
|
-
- Volume measurements (3D): multiplied by pixel_size_um
|
|
287
|
-
- Dimensionless properties: unchanged
|
|
292
|
+
- Linear measurements (1D): multiplied by pixel_size_um, keys suffixed with "_um"
|
|
293
|
+
- Area measurements (2D): multiplied by pixel_size_um², keys suffixed with "_um2"
|
|
294
|
+
- Volume measurements (3D): multiplied by pixel_size_um³, keys suffixed with "_um3"
|
|
295
|
+
- Dimensionless properties: unchanged, keys unchanged
|
|
288
296
|
|
|
289
297
|
Args:
|
|
290
298
|
pixel_size_um: Pixel size in microns.
|
|
291
299
|
|
|
292
300
|
Returns:
|
|
293
|
-
Dictionary with
|
|
301
|
+
Dictionary with keys renamed to include units and values
|
|
294
302
|
converted to micron units where applicable.
|
|
295
303
|
|
|
296
304
|
Note:
|
|
297
|
-
Properties like 'label', 'circularity', 'eccentricity', 'solidity',
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
Tensor properties (inertia_tensor, inertia_tensor_eigvals) are scaled
|
|
302
|
-
|
|
305
|
+
Properties like 'label', 'circularity', 'eccentricity', 'solidity', and 'orientation'
|
|
306
|
+
are dimensionless and remain unchanged. Intensity properties (intensity_mean,
|
|
307
|
+
intensity_max, etc.) are also dimensionless and remain unchanged. Centroid coordinates
|
|
308
|
+
(centroid_y, centroid_x) remain in pixel coordinates as they represent image positions.
|
|
309
|
+
Tensor properties (inertia_tensor, inertia_tensor_eigvals) are scaled as 2D quantities
|
|
310
|
+
(pixel_size_um²) and suffixed with "_um2".
|
|
303
311
|
"""
|
|
304
312
|
# Define which properties need which scaling
|
|
305
|
-
linear_properties = {
|
|
306
|
-
"perimeter",
|
|
307
|
-
"axis_major_length",
|
|
308
|
-
"axis_minor_length",
|
|
309
|
-
"centroid-0",
|
|
310
|
-
"centroid-1",
|
|
311
|
-
}
|
|
313
|
+
linear_properties = {"perimeter", "axis_major_length", "axis_minor_length"}
|
|
312
314
|
area_properties = {"area", "area_convex"}
|
|
313
315
|
volume_properties = {"volume"}
|
|
314
316
|
tensor_properties = {"inertia_tensor", "inertia_tensor_eigvals"}
|
|
@@ -316,13 +318,13 @@ class SegmentationMask:
|
|
|
316
318
|
converted = {}
|
|
317
319
|
for prop_name, prop_values in self.cell_properties.items():
|
|
318
320
|
if prop_name in linear_properties:
|
|
319
|
-
converted[prop_name] = prop_values * pixel_size_um
|
|
321
|
+
converted[f"{prop_name}_um"] = prop_values * pixel_size_um
|
|
320
322
|
elif prop_name in area_properties:
|
|
321
|
-
converted[prop_name] = prop_values * (pixel_size_um**2)
|
|
323
|
+
converted[f"{prop_name}_um2"] = prop_values * (pixel_size_um**2)
|
|
322
324
|
elif prop_name in volume_properties:
|
|
323
|
-
converted[prop_name] = prop_values * (pixel_size_um**3)
|
|
325
|
+
converted[f"{prop_name}_um3"] = prop_values * (pixel_size_um**3)
|
|
324
326
|
elif prop_name in tensor_properties:
|
|
325
|
-
converted[prop_name] = prop_values * (pixel_size_um**2)
|
|
327
|
+
converted[f"{prop_name}_um2"] = prop_values * (pixel_size_um**2)
|
|
326
328
|
else:
|
|
327
329
|
# Intensity-related, dimensionless, or label - no conversion
|
|
328
330
|
converted[prop_name] = prop_values
|
|
@@ -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
|
|
@@ -7,7 +7,7 @@ import numpy as np
|
|
|
7
7
|
import torch
|
|
8
8
|
from cellpose.models import CellposeModel
|
|
9
9
|
|
|
10
|
-
from .typing import
|
|
10
|
+
from .typing import Float64Array, Int64Array
|
|
11
11
|
|
|
12
12
|
logger = logging.getLogger(__name__)
|
|
13
13
|
|
|
@@ -167,7 +167,7 @@ class SegmentationModel:
|
|
|
167
167
|
|
|
168
168
|
def segment(
|
|
169
169
|
self,
|
|
170
|
-
intensities:
|
|
170
|
+
intensities: Float64Array,
|
|
171
171
|
cell_diameter_px: float | None = None,
|
|
172
172
|
flow_threshold: float | None = None,
|
|
173
173
|
cellprob_threshold: float | None = None,
|
|
@@ -213,7 +213,7 @@ class SegmentationModel:
|
|
|
213
213
|
|
|
214
214
|
def batch_segment(
|
|
215
215
|
self,
|
|
216
|
-
intensities_batch: Sequence[
|
|
216
|
+
intensities_batch: Sequence[Float64Array],
|
|
217
217
|
cell_diameter_px: float | None = None,
|
|
218
218
|
flow_threshold: float | None = None,
|
|
219
219
|
cellprob_threshold: float | None = None,
|
|
@@ -16,7 +16,7 @@ from .metadata_structures import (
|
|
|
16
16
|
PhysicalDimensions,
|
|
17
17
|
)
|
|
18
18
|
from .microscopy import ImageMetadata
|
|
19
|
-
from .typing import
|
|
19
|
+
from .typing import Float64Array
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def create_image_metadata_from_nd2(
|
|
@@ -247,7 +247,7 @@ class _NikonMetadataParser:
|
|
|
247
247
|
return time_s * 1000 # Convert to ms for AcquisitionSettings
|
|
248
248
|
return None
|
|
249
249
|
|
|
250
|
-
def _parse_frame_intervals(self) ->
|
|
250
|
+
def _parse_frame_intervals(self) -> Float64Array | None:
|
|
251
251
|
"""Parse frame intervals from events metadata."""
|
|
252
252
|
if self._nd2f.events():
|
|
253
253
|
acquisition_start_times_s = [event["Time [s]"] for event in self._nd2f.events()]
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
from typing import Literal
|
|
2
3
|
|
|
3
4
|
import numpy as np
|
|
4
5
|
import skimage as ski
|
|
5
6
|
|
|
6
|
-
from .typing import
|
|
7
|
+
from .typing import BoolArray, Float64Array, ScalarArray
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
def rescale_by_percentile(
|
|
@@ -18,11 +19,9 @@ def rescale_by_percentile(
|
|
|
18
19
|
|
|
19
20
|
Args:
|
|
20
21
|
intensities: Input image array.
|
|
21
|
-
percentile_range:
|
|
22
|
-
Tuple of (min, max) percentiles to use for intensity scaling.
|
|
22
|
+
percentile_range: Tuple of (min, max) percentiles to use for intensity scaling.
|
|
23
23
|
Default is (0, 100) which uses the full intensity range.
|
|
24
|
-
out_range:
|
|
25
|
-
Tuple of (min, max) values for the output intensity range.
|
|
24
|
+
out_range: Tuple of (min, max) values for the output intensity range.
|
|
26
25
|
Default is (0, 1) for float normalization.
|
|
27
26
|
|
|
28
27
|
Returns:
|
|
@@ -60,7 +59,7 @@ def subtract_background_dog(
|
|
|
60
59
|
low_sigma: float = 0.6,
|
|
61
60
|
high_sigma: float = 16.0,
|
|
62
61
|
percentile: float = 0,
|
|
63
|
-
) ->
|
|
62
|
+
) -> Float64Array:
|
|
64
63
|
"""Subtract background from image using difference of Gaussians and percentile thresholding.
|
|
65
64
|
|
|
66
65
|
Applies difference of Gaussians filter to enhance features and then estimates and subtracts
|
|
@@ -68,18 +67,15 @@ def subtract_background_dog(
|
|
|
68
67
|
|
|
69
68
|
Args:
|
|
70
69
|
intensities: Input image array.
|
|
71
|
-
low_sigma:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
extent. Default is 16.
|
|
77
|
-
percentile:
|
|
78
|
-
Percentile of filtered image to use as background level (0-100).
|
|
70
|
+
low_sigma: Standard deviation for the smaller Gaussian kernel.
|
|
71
|
+
Controls fine detail enhancement. Default is 0.6.
|
|
72
|
+
high_sigma: Standard deviation for the larger Gaussian kernel.
|
|
73
|
+
Controls background estimation extent. Default is 16.
|
|
74
|
+
percentile: Percentile of filtered image to use as background level (0-100).
|
|
79
75
|
Default is 0 (minimum value).
|
|
80
76
|
|
|
81
77
|
Returns:
|
|
82
|
-
|
|
78
|
+
Float64Array: Background-subtracted image with negative values clipped to zero.
|
|
83
79
|
|
|
84
80
|
Notes:
|
|
85
81
|
- For best results, low_sigma should be smaller than the smallest feature of interest,
|
|
@@ -134,3 +130,87 @@ def crop_to_center(
|
|
|
134
130
|
top = (height - crop_height) // 2
|
|
135
131
|
|
|
136
132
|
return intensities[..., top : top + crop_height, left : left + crop_width]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def apply_threshold(
|
|
136
|
+
intensities: ScalarArray,
|
|
137
|
+
method: Literal[
|
|
138
|
+
"otsu",
|
|
139
|
+
"li",
|
|
140
|
+
"yen",
|
|
141
|
+
"isodata",
|
|
142
|
+
"mean",
|
|
143
|
+
"minimum",
|
|
144
|
+
"triangle",
|
|
145
|
+
"local",
|
|
146
|
+
"niblack",
|
|
147
|
+
"sauvola",
|
|
148
|
+
] = "otsu",
|
|
149
|
+
**kwargs,
|
|
150
|
+
) -> BoolArray:
|
|
151
|
+
"""Apply thresholding to convert grayscale image to binary using various methods.
|
|
152
|
+
|
|
153
|
+
Uses threshold calculation methods from skimage.filters to determine an optimal
|
|
154
|
+
threshold value, then applies it to create a binary image.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
intensities: Input grayscale image array.
|
|
158
|
+
method: Thresholding method to use. Supported methods include:
|
|
159
|
+
- 'otsu': Otsu's method (default)
|
|
160
|
+
- 'li': Li's minimum cross entropy method
|
|
161
|
+
- 'yen': Yen's method
|
|
162
|
+
- 'isodata': ISODATA method
|
|
163
|
+
- 'mean': Mean-based threshold
|
|
164
|
+
- 'minimum': Minimum method
|
|
165
|
+
- 'triangle': Triangle algorithm
|
|
166
|
+
- 'local': Adaptive local threshold
|
|
167
|
+
- 'niblack': Niblack local threshold
|
|
168
|
+
- 'sauvola': Sauvola local threshold
|
|
169
|
+
**kwargs: Additional keyword arguments passed to the thresholding function.
|
|
170
|
+
For local methods (niblack, sauvola, local), common kwargs include:
|
|
171
|
+
- window_size: Size of the local neighborhood
|
|
172
|
+
- k: Parameter controlling threshold adjustment
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
BoolArray: Binary image where pixels above threshold are True.
|
|
176
|
+
|
|
177
|
+
Raises:
|
|
178
|
+
ValueError: If the specified method is not supported.
|
|
179
|
+
|
|
180
|
+
Examples:
|
|
181
|
+
>>> binary = apply_threshold(image, method='otsu')
|
|
182
|
+
>>> binary = apply_threshold(image, method='sauvola', window_size=25)
|
|
183
|
+
"""
|
|
184
|
+
# Map method names to skimage.filters threshold functions
|
|
185
|
+
threshold_methods = {
|
|
186
|
+
"otsu": ski.filters.threshold_otsu,
|
|
187
|
+
"li": ski.filters.threshold_li,
|
|
188
|
+
"yen": ski.filters.threshold_yen,
|
|
189
|
+
"isodata": ski.filters.threshold_isodata,
|
|
190
|
+
"mean": ski.filters.threshold_mean,
|
|
191
|
+
"minimum": ski.filters.threshold_minimum,
|
|
192
|
+
"triangle": ski.filters.threshold_triangle,
|
|
193
|
+
"local": ski.filters.threshold_local,
|
|
194
|
+
"niblack": ski.filters.threshold_niblack,
|
|
195
|
+
"sauvola": ski.filters.threshold_sauvola,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
# Handle empty or constant images
|
|
199
|
+
if intensities.size == 0:
|
|
200
|
+
return np.zeros_like(intensities, dtype=bool)
|
|
201
|
+
if np.min(intensities) == np.max(intensities):
|
|
202
|
+
return np.zeros_like(intensities, dtype=bool)
|
|
203
|
+
|
|
204
|
+
method_lower = method.lower()
|
|
205
|
+
if method_lower not in threshold_methods:
|
|
206
|
+
raise ValueError(
|
|
207
|
+
f"Unsupported thresholding method: '{method}'. "
|
|
208
|
+
f"Supported methods: {', '.join(threshold_methods.keys())}"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
threshold_func = threshold_methods[method_lower]
|
|
212
|
+
|
|
213
|
+
# Local methods (niblack, sauvola) return threshold array, others return scalar
|
|
214
|
+
threshold_value = threshold_func(intensities, **kwargs)
|
|
215
|
+
|
|
216
|
+
return intensities > threshold_value
|
|
@@ -54,10 +54,14 @@ class Pipeline:
|
|
|
54
54
|
operations: List of ImageOperation instances to apply in sequence.
|
|
55
55
|
copy: If True, creates a copy of the input array before processing. If False,
|
|
56
56
|
operations are applied directly to the input. Default is False for performance.
|
|
57
|
+
preserve_dtype: If True, forces output to have the same dtype as input. If False,
|
|
58
|
+
allows dtype to change based on operations (e.g., uint16 -> float64 for
|
|
59
|
+
normalization). Default is True.
|
|
57
60
|
"""
|
|
58
61
|
|
|
59
62
|
operations: list[ImageOperation]
|
|
60
63
|
copy: bool = False
|
|
64
|
+
preserve_dtype: bool = True
|
|
61
65
|
|
|
62
66
|
def __post_init__(self):
|
|
63
67
|
"""Validate the pipeline configuration."""
|
|
@@ -80,7 +84,10 @@ class Pipeline:
|
|
|
80
84
|
Returns:
|
|
81
85
|
ScalarArray: The processed image intensity array after applying all operations.
|
|
82
86
|
"""
|
|
83
|
-
|
|
87
|
+
result = self._apply_operations(intensities)
|
|
88
|
+
if self.preserve_dtype and result.dtype != intensities.dtype:
|
|
89
|
+
return result.astype(intensities.dtype) # type: ignore
|
|
90
|
+
return result
|
|
84
91
|
|
|
85
92
|
def __len__(self) -> int:
|
|
86
93
|
"""Return the number of operations in the pipeline."""
|
|
@@ -89,8 +96,13 @@ class Pipeline:
|
|
|
89
96
|
def __repr__(self) -> str:
|
|
90
97
|
"""Create a string representation of the pipeline."""
|
|
91
98
|
operations_repr = ", ".join(repr(operation) for operation in self.operations)
|
|
92
|
-
|
|
93
|
-
|
|
99
|
+
params = []
|
|
100
|
+
if self.copy:
|
|
101
|
+
params.append("copy=True")
|
|
102
|
+
if not self.preserve_dtype:
|
|
103
|
+
params.append("preserve_dtype=False")
|
|
104
|
+
params_str = f", {', '.join(params)}" if params else ""
|
|
105
|
+
return f"Pipeline([{operations_repr}]{params_str})"
|
|
94
106
|
|
|
95
107
|
|
|
96
108
|
@dataclass
|
|
@@ -106,10 +118,13 @@ class PipelineParallelized:
|
|
|
106
118
|
|
|
107
119
|
Attributes:
|
|
108
120
|
operations: List of ImageOperation instances to apply in sequence.
|
|
109
|
-
max_workers: Maximum number of worker threads for parallel processing. If None,
|
|
110
|
-
ThreadPoolExecutor will use its default (typically number of CPU cores).
|
|
111
121
|
copy: If True, creates a copy of each frame before processing. If False,
|
|
112
122
|
operations are applied directly to each frame. Default is False for performance.
|
|
123
|
+
preserve_dtype: If True, forces output to have the same dtype as input. If False,
|
|
124
|
+
allows dtype to change based on operations (e.g., uint16 -> float64 for
|
|
125
|
+
normalization). Default is True.
|
|
126
|
+
max_workers: Maximum number of worker threads for parallel processing. If None,
|
|
127
|
+
ThreadPoolExecutor will use its default (typically number of CPU cores).
|
|
113
128
|
|
|
114
129
|
Note:
|
|
115
130
|
Uses thread-based parallelism, which is most effective for operations that release
|
|
@@ -118,8 +133,9 @@ class PipelineParallelized:
|
|
|
118
133
|
"""
|
|
119
134
|
|
|
120
135
|
operations: list[ImageOperation]
|
|
121
|
-
max_workers: int | None = None
|
|
122
136
|
copy: bool = False
|
|
137
|
+
preserve_dtype: bool = True
|
|
138
|
+
max_workers: int | None = None
|
|
123
139
|
|
|
124
140
|
def __post_init__(self):
|
|
125
141
|
"""Validate the pipeline configuration."""
|
|
@@ -145,7 +161,10 @@ class PipelineParallelized:
|
|
|
145
161
|
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
|
146
162
|
processed = list(executor.map(self._apply_operations, intensities))
|
|
147
163
|
|
|
148
|
-
|
|
164
|
+
if self.preserve_dtype:
|
|
165
|
+
return np.array(processed, dtype=intensities.dtype) # type: ignore
|
|
166
|
+
else:
|
|
167
|
+
return np.array(processed) # type: ignore
|
|
149
168
|
|
|
150
169
|
def __len__(self) -> int:
|
|
151
170
|
"""Return the number of operations in the pipeline."""
|
|
@@ -155,9 +174,11 @@ class PipelineParallelized:
|
|
|
155
174
|
"""Create a string representation of the pipeline."""
|
|
156
175
|
operations_repr = ", ".join(repr(operation) for operation in self.operations)
|
|
157
176
|
params = []
|
|
158
|
-
if self.max_workers is not None:
|
|
159
|
-
params.append(f"max_workers={self.max_workers}")
|
|
160
177
|
if self.copy:
|
|
161
178
|
params.append("copy=True")
|
|
179
|
+
if not self.preserve_dtype:
|
|
180
|
+
params.append("preserve_dtype=False")
|
|
181
|
+
if self.max_workers is not None:
|
|
182
|
+
params.append(f"max_workers={self.max_workers}")
|
|
162
183
|
params_str = f", {', '.join(params)}" if params else ""
|
|
163
184
|
return f"PipelineParallelized([{operations_repr}]{params_str})"
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from arcadia_microscopy_tools.pipeline import ImageOperation, Pipeline, PipelineParallelized
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Simple test operations for testing
|
|
8
|
+
def double_intensity(intensities):
|
|
9
|
+
"""Double all intensity values."""
|
|
10
|
+
return intensities * 2
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def add_ten(intensities):
|
|
14
|
+
"""Add 10 to all intensity values."""
|
|
15
|
+
return intensities + 10
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def to_float_normalized(intensities):
|
|
19
|
+
"""Convert to float and normalize to [0, 1]."""
|
|
20
|
+
return intensities.astype(float) / intensities.max()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def square_values(intensities):
|
|
24
|
+
"""Square all values."""
|
|
25
|
+
return intensities**2
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TestImageOperation:
|
|
29
|
+
def test_create_operation_no_args(self):
|
|
30
|
+
op = ImageOperation(double_intensity)
|
|
31
|
+
assert op.method == double_intensity
|
|
32
|
+
assert op.args == ()
|
|
33
|
+
assert op.kwargs == {}
|
|
34
|
+
|
|
35
|
+
def test_create_operation_with_args(self):
|
|
36
|
+
op = ImageOperation(np.add, 5)
|
|
37
|
+
assert op.method == np.add
|
|
38
|
+
assert op.args == (5,)
|
|
39
|
+
|
|
40
|
+
def test_create_operation_with_kwargs(self):
|
|
41
|
+
op = ImageOperation(np.clip, a_min=0, a_max=100)
|
|
42
|
+
assert op.kwargs == {"a_min": 0, "a_max": 100}
|
|
43
|
+
|
|
44
|
+
def test_call_operation(self):
|
|
45
|
+
op = ImageOperation(double_intensity)
|
|
46
|
+
image = np.array([1, 2, 3])
|
|
47
|
+
result = op(image)
|
|
48
|
+
np.testing.assert_array_equal(result, [2, 4, 6])
|
|
49
|
+
|
|
50
|
+
def test_call_operation_with_args(self):
|
|
51
|
+
op = ImageOperation(np.add, 10)
|
|
52
|
+
image = np.array([1, 2, 3])
|
|
53
|
+
result = op(image)
|
|
54
|
+
np.testing.assert_array_equal(result, [11, 12, 13])
|
|
55
|
+
|
|
56
|
+
def test_repr(self):
|
|
57
|
+
op = ImageOperation(double_intensity)
|
|
58
|
+
assert "double_intensity" in repr(op)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestPipeline:
|
|
62
|
+
def test_create_pipeline(self):
|
|
63
|
+
ops = [ImageOperation(double_intensity), ImageOperation(add_ten)]
|
|
64
|
+
pipeline = Pipeline(operations=ops)
|
|
65
|
+
assert len(pipeline) == 2
|
|
66
|
+
assert pipeline.copy is False
|
|
67
|
+
assert pipeline.preserve_dtype is True
|
|
68
|
+
|
|
69
|
+
def test_create_pipeline_with_copy(self):
|
|
70
|
+
ops = [ImageOperation(double_intensity)]
|
|
71
|
+
pipeline = Pipeline(operations=ops, copy=True)
|
|
72
|
+
assert pipeline.copy is True
|
|
73
|
+
|
|
74
|
+
def test_create_pipeline_with_preserve_dtype_false(self):
|
|
75
|
+
ops = [ImageOperation(to_float_normalized)]
|
|
76
|
+
pipeline = Pipeline(operations=ops, preserve_dtype=False)
|
|
77
|
+
assert pipeline.preserve_dtype is False
|
|
78
|
+
|
|
79
|
+
def test_pipeline_requires_operations(self):
|
|
80
|
+
with pytest.raises(ValueError, match="at least one operation"):
|
|
81
|
+
Pipeline(operations=[])
|
|
82
|
+
|
|
83
|
+
def test_pipeline_single_operation(self):
|
|
84
|
+
pipeline = Pipeline(operations=[ImageOperation(double_intensity)])
|
|
85
|
+
image = np.array([1, 2, 3], dtype=np.uint16)
|
|
86
|
+
result = pipeline(image)
|
|
87
|
+
np.testing.assert_array_equal(result, [2, 4, 6])
|
|
88
|
+
assert result.dtype == np.uint16
|
|
89
|
+
|
|
90
|
+
def test_pipeline_multiple_operations(self):
|
|
91
|
+
pipeline = Pipeline(operations=[ImageOperation(double_intensity), ImageOperation(add_ten)])
|
|
92
|
+
image = np.array([1, 2, 3], dtype=np.uint16)
|
|
93
|
+
result = pipeline(image)
|
|
94
|
+
# First double: [2, 4, 6], then add 10: [12, 14, 16]
|
|
95
|
+
np.testing.assert_array_equal(result, [12, 14, 16])
|
|
96
|
+
assert result.dtype == np.uint16
|
|
97
|
+
|
|
98
|
+
def test_pipeline_preserve_dtype_default(self):
|
|
99
|
+
"""Test that dtype is preserved by default when it changes."""
|
|
100
|
+
pipeline = Pipeline(operations=[ImageOperation(to_float_normalized)])
|
|
101
|
+
image = np.array([10, 20, 30], dtype=np.uint16)
|
|
102
|
+
result = pipeline(image)
|
|
103
|
+
# to_float_normalized returns float, but preserve_dtype=True should cast back
|
|
104
|
+
assert result.dtype == np.uint16
|
|
105
|
+
|
|
106
|
+
def test_pipeline_preserve_dtype_false(self):
|
|
107
|
+
"""Test that dtype can change when preserve_dtype=False."""
|
|
108
|
+
pipeline = Pipeline(operations=[ImageOperation(to_float_normalized)], preserve_dtype=False)
|
|
109
|
+
image = np.array([10, 20, 30], dtype=np.uint16)
|
|
110
|
+
result = pipeline(image)
|
|
111
|
+
# Should return float
|
|
112
|
+
assert result.dtype in (np.float32, np.float64)
|
|
113
|
+
np.testing.assert_allclose(result, [1 / 3, 2 / 3, 1.0])
|
|
114
|
+
|
|
115
|
+
def test_pipeline_with_2d_image(self):
|
|
116
|
+
"""Test pipeline with 2D image arrays."""
|
|
117
|
+
pipeline = Pipeline(operations=[ImageOperation(double_intensity)])
|
|
118
|
+
image = np.array([[1, 2], [3, 4]], dtype=np.uint16)
|
|
119
|
+
result = pipeline(image)
|
|
120
|
+
expected = np.array([[2, 4], [6, 8]], dtype=np.uint16)
|
|
121
|
+
np.testing.assert_array_equal(result, expected)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class TestPipelineParallelized:
|
|
125
|
+
def test_create_pipeline_parallelized(self):
|
|
126
|
+
ops = [ImageOperation(double_intensity)]
|
|
127
|
+
pipeline = PipelineParallelized(operations=ops)
|
|
128
|
+
assert len(pipeline) == 1
|
|
129
|
+
assert pipeline.max_workers is None
|
|
130
|
+
assert pipeline.copy is False
|
|
131
|
+
assert pipeline.preserve_dtype is True
|
|
132
|
+
|
|
133
|
+
def test_create_pipeline_with_max_workers(self):
|
|
134
|
+
ops = [ImageOperation(double_intensity)]
|
|
135
|
+
pipeline = PipelineParallelized(operations=ops, max_workers=4)
|
|
136
|
+
assert pipeline.max_workers == 4
|
|
137
|
+
|
|
138
|
+
def test_pipeline_parallelized_requires_operations(self):
|
|
139
|
+
with pytest.raises(ValueError, match="at least one operation"):
|
|
140
|
+
PipelineParallelized(operations=[])
|
|
141
|
+
|
|
142
|
+
def test_pipeline_parallelized_3d_array(self):
|
|
143
|
+
"""Test parallel processing of 3D array (e.g., time series)."""
|
|
144
|
+
pipeline = PipelineParallelized(operations=[ImageOperation(double_intensity)])
|
|
145
|
+
# Create 3D array: (time, height, width)
|
|
146
|
+
image = np.array([[[1, 2], [3, 4]], [[5, 6], [7, 8]], [[9, 10], [11, 12]]], dtype=np.uint16)
|
|
147
|
+
result = pipeline(image)
|
|
148
|
+
expected = image * 2
|
|
149
|
+
np.testing.assert_array_equal(result, expected)
|
|
150
|
+
assert result.dtype == np.uint16
|
|
151
|
+
|
|
152
|
+
def test_pipeline_parallelized_preserve_dtype_default(self):
|
|
153
|
+
"""Test that dtype is preserved by default."""
|
|
154
|
+
pipeline = PipelineParallelized(operations=[ImageOperation(to_float_normalized)])
|
|
155
|
+
image = np.array([[[10, 20], [30, 40]]], dtype=np.uint16)
|
|
156
|
+
result = pipeline(image)
|
|
157
|
+
# Should preserve uint16 dtype by default
|
|
158
|
+
assert result.dtype == np.uint16
|
|
159
|
+
|
|
160
|
+
def test_pipeline_parallelized_preserve_dtype_false(self):
|
|
161
|
+
"""Test that dtype can change when preserve_dtype=False."""
|
|
162
|
+
pipeline = PipelineParallelized(
|
|
163
|
+
operations=[ImageOperation(to_float_normalized)], preserve_dtype=False
|
|
164
|
+
)
|
|
165
|
+
image = np.array([[[10, 20], [30, 40]]], dtype=np.uint16)
|
|
166
|
+
result = pipeline(image)
|
|
167
|
+
# Should return float
|
|
168
|
+
assert result.dtype in (np.float32, np.float64)
|
|
169
|
+
|
|
170
|
+
def test_pipeline_parallelized_multiple_operations(self):
|
|
171
|
+
"""Test multiple operations in parallel pipeline."""
|
|
172
|
+
pipeline = PipelineParallelized(
|
|
173
|
+
operations=[ImageOperation(double_intensity), ImageOperation(add_ten)]
|
|
174
|
+
)
|
|
175
|
+
image = np.array([[[1, 2], [3, 4]], [[5, 6], [7, 8]]], dtype=np.uint16)
|
|
176
|
+
result = pipeline(image)
|
|
177
|
+
# First double, then add 10
|
|
178
|
+
expected = (image * 2) + 10
|
|
179
|
+
np.testing.assert_array_equal(result, expected)
|
|
180
|
+
|
|
181
|
+
def test_pipeline_parallelized_single_frame(self):
|
|
182
|
+
"""Test with single frame (edge case)."""
|
|
183
|
+
pipeline = PipelineParallelized(operations=[ImageOperation(double_intensity)])
|
|
184
|
+
image = np.array([[[1, 2], [3, 4]]], dtype=np.uint16)
|
|
185
|
+
result = pipeline(image)
|
|
186
|
+
expected = image * 2
|
|
187
|
+
np.testing.assert_array_equal(result, expected)
|
|
188
|
+
|
|
189
|
+
def test_pipeline_parallelized_many_frames(self):
|
|
190
|
+
"""Test with many frames to ensure parallelization works."""
|
|
191
|
+
pipeline = PipelineParallelized(
|
|
192
|
+
operations=[ImageOperation(double_intensity)], max_workers=2
|
|
193
|
+
)
|
|
194
|
+
# Create 10 frames
|
|
195
|
+
image = np.random.randint(0, 100, size=(10, 32, 32), dtype=np.uint16)
|
|
196
|
+
result = pipeline(image)
|
|
197
|
+
expected = image * 2
|
|
198
|
+
np.testing.assert_array_equal(result, expected)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class TestPipelineIntegration:
|
|
202
|
+
"""Integration tests for realistic use cases."""
|
|
203
|
+
|
|
204
|
+
def test_normalization_workflow_preserve_dtype_false(self):
|
|
205
|
+
"""Test a realistic normalization workflow for ML preprocessing."""
|
|
206
|
+
from arcadia_microscopy_tools.operations import rescale_by_percentile
|
|
207
|
+
|
|
208
|
+
# Simulate 16-bit microscopy images (3 frames)
|
|
209
|
+
image = np.random.randint(0, 65535, size=(3, 128, 128), dtype=np.uint16)
|
|
210
|
+
|
|
211
|
+
pipeline = PipelineParallelized(
|
|
212
|
+
operations=[
|
|
213
|
+
ImageOperation(
|
|
214
|
+
rescale_by_percentile,
|
|
215
|
+
percentile_range=(2, 98),
|
|
216
|
+
out_range=(0, 1),
|
|
217
|
+
)
|
|
218
|
+
],
|
|
219
|
+
preserve_dtype=False,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
result = pipeline(image)
|
|
223
|
+
|
|
224
|
+
# Should be normalized to [0, 1] float range
|
|
225
|
+
assert result.dtype in (np.float32, np.float64)
|
|
226
|
+
assert result.min() >= 0
|
|
227
|
+
assert result.max() <= 1
|
|
228
|
+
|
|
229
|
+
def test_normalization_workflow_preserve_dtype_true(self):
|
|
230
|
+
"""Test normalization with dtype preservation (legacy behavior)."""
|
|
231
|
+
from arcadia_microscopy_tools.operations import rescale_by_percentile
|
|
232
|
+
|
|
233
|
+
# Simulate 16-bit microscopy images
|
|
234
|
+
image = np.random.randint(0, 65535, size=(3, 128, 128), dtype=np.uint16)
|
|
235
|
+
|
|
236
|
+
pipeline = PipelineParallelized(
|
|
237
|
+
operations=[
|
|
238
|
+
ImageOperation(
|
|
239
|
+
rescale_by_percentile,
|
|
240
|
+
percentile_range=(2, 98),
|
|
241
|
+
out_range=(0, 65535),
|
|
242
|
+
)
|
|
243
|
+
],
|
|
244
|
+
preserve_dtype=True,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
result = pipeline(image)
|
|
248
|
+
|
|
249
|
+
# Should stay as uint16
|
|
250
|
+
assert result.dtype == np.uint16
|
|
251
|
+
|
|
252
|
+
def test_background_subtraction_and_normalization(self):
|
|
253
|
+
"""Test combining background subtraction with normalization."""
|
|
254
|
+
from arcadia_microscopy_tools.operations import (
|
|
255
|
+
rescale_by_percentile,
|
|
256
|
+
subtract_background_dog,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# Create test image with background
|
|
260
|
+
image = np.random.randint(100, 200, size=(2, 64, 64), dtype=np.uint16)
|
|
261
|
+
|
|
262
|
+
pipeline = PipelineParallelized(
|
|
263
|
+
operations=[
|
|
264
|
+
ImageOperation(subtract_background_dog, low_sigma=1, high_sigma=10),
|
|
265
|
+
ImageOperation(
|
|
266
|
+
rescale_by_percentile,
|
|
267
|
+
percentile_range=(1, 99),
|
|
268
|
+
out_range=(0, 1),
|
|
269
|
+
),
|
|
270
|
+
],
|
|
271
|
+
preserve_dtype=False,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
result = pipeline(image)
|
|
275
|
+
|
|
276
|
+
# Should be float after processing
|
|
277
|
+
assert result.dtype in (np.float32, np.float64)
|
|
278
|
+
assert result.shape == image.shape
|
|
@@ -2,8 +2,9 @@ import numpy as np
|
|
|
2
2
|
from numpy.typing import NDArray
|
|
3
3
|
|
|
4
4
|
BoolArray = NDArray[np.bool_]
|
|
5
|
-
|
|
6
|
-
Int64Array = NDArray[np.int64]
|
|
5
|
+
UByteArray = NDArray[np.uint8]
|
|
7
6
|
UInt16Array = NDArray[np.uint16]
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
Int64Array = NDArray[np.int64]
|
|
8
|
+
Float64Array = NDArray[np.float64]
|
|
9
|
+
|
|
10
|
+
ScalarArray = BoolArray | Float64Array | Int64Array | UInt16Array | UByteArray
|
{arcadia_microscopy_tools-0.2.3.dist-info → arcadia_microscopy_tools-0.2.5.dist-info}/RECORD
RENAMED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
arcadia_microscopy_tools/__init__.py,sha256=
|
|
2
|
-
arcadia_microscopy_tools/blending.py,sha256=
|
|
1
|
+
arcadia_microscopy_tools/__init__.py,sha256=HmMHWrlDh8pAB6f_an39kuDiujpPl4UBAvL0eBd1Bdo,440
|
|
2
|
+
arcadia_microscopy_tools/blending.py,sha256=Y5xuius1tHRKIEfueUjZ7qGlBK02arEYqrzRRhEdSTI,7112
|
|
3
3
|
arcadia_microscopy_tools/channels.py,sha256=sE54mJoJnFIMowO_qRG4lx-s_LOaVO10tuxpuVadJg8,6854
|
|
4
|
-
arcadia_microscopy_tools/masks.py,sha256=
|
|
5
|
-
arcadia_microscopy_tools/metadata_structures.py,sha256=
|
|
4
|
+
arcadia_microscopy_tools/masks.py,sha256=112486kqgMwNxLvTk9FBj8Rdo-cMOaOpToFD1_RwUQ0,15800
|
|
5
|
+
arcadia_microscopy_tools/metadata_structures.py,sha256=Bb4UXgiNuJcOITNGV_4hGR09HaN8Wt7heET4bXmNcw0,3481
|
|
6
6
|
arcadia_microscopy_tools/microplate.py,sha256=df6HTeQdYQRD7rYKubx8_FWOZ1BbJVoyg7lYySHJQOU,8298
|
|
7
7
|
arcadia_microscopy_tools/microscopy.py,sha256=gPvMVKukGkBY74Ajy71JOd7DfZOoCEfiE40qkBW-C-I,11313
|
|
8
|
-
arcadia_microscopy_tools/model.py,sha256=
|
|
9
|
-
arcadia_microscopy_tools/nikon.py,sha256=
|
|
10
|
-
arcadia_microscopy_tools/operations.py,sha256=
|
|
11
|
-
arcadia_microscopy_tools/pipeline.py,sha256=
|
|
12
|
-
arcadia_microscopy_tools/typing.py,sha256=
|
|
8
|
+
arcadia_microscopy_tools/model.py,sha256=IfDmTE7zUW2xa0O1fA0kQd_SL7Ank0_p-tIx-_UYdII,12488
|
|
9
|
+
arcadia_microscopy_tools/nikon.py,sha256=RdkpN2M77h7_2Bb28nEou6m2MAEIjvwPvxxCHmWzf98,10811
|
|
10
|
+
arcadia_microscopy_tools/operations.py,sha256=lI1SazqOGzYdQtMR2aF3aKwhglul5XJz8qZwYMQwAJE,7939
|
|
11
|
+
arcadia_microscopy_tools/pipeline.py,sha256=uq0O7dwFLiGWMNAZw1rdTbWFRML0Zvk2as9cAvjVQAo,7338
|
|
12
|
+
arcadia_microscopy_tools/typing.py,sha256=cGGvVo4cuW7xtL5MUCBPMW9_8H5yB8GFL_N1xdgBo78,293
|
|
13
13
|
arcadia_microscopy_tools/utils.py,sha256=W2x4q7pbPrVvZ0xZiW4BSyvJZ5bHDZ16am520_zA6rc,2970
|
|
14
14
|
arcadia_microscopy_tools/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
15
|
arcadia_microscopy_tools/tests/conftest.py,sha256=NJh9D_k_rrtHdnUfWtsc5CDbqbFIhZhXgRcFb7Dqc78,1461
|
|
@@ -17,6 +17,7 @@ arcadia_microscopy_tools/tests/test_channels.py,sha256=ztrnknb9cZuIR6hwTmgQ5uP51
|
|
|
17
17
|
arcadia_microscopy_tools/tests/test_microplate.py,sha256=3xVdv958IwH52XTThuqWuj5ZGrJrCfJqAY2Vi93IPPo,1838
|
|
18
18
|
arcadia_microscopy_tools/tests/test_microscopy.py,sha256=nLiCG3sQ1huZJu2W11k9UCmSBCGDCXsXGb_9G4h1jAg,2062
|
|
19
19
|
arcadia_microscopy_tools/tests/test_model.py,sha256=aVqAalWoyB_Ay03Y5ErLXIgKBqnUBorxflvllzBO4eQ,19743
|
|
20
|
+
arcadia_microscopy_tools/tests/test_pipeline.py,sha256=rlMPiRwOKGnL5uIlgTu0_oMtt6bAGLcBSmPNi654SxQ,10643
|
|
20
21
|
arcadia_microscopy_tools/tests/data/README.md,sha256=TLV01Qo_QYTqIk351Q_9hO9NrR5FARnezKDZbI5oREc,1683
|
|
21
22
|
arcadia_microscopy_tools/tests/data/example-cerevisiae.nd2,sha256=ymhS7GciOyD6XJqJvYF3iZRZtnEf-6n_z_MHjufIGoE,503808
|
|
22
23
|
arcadia_microscopy_tools/tests/data/example-multichannel.nd2,sha256=_Ol47B_PK5GcF8ruBBvBElOa17nfLjeQsiYp0UUpkx0,876544
|
|
@@ -24,7 +25,7 @@ arcadia_microscopy_tools/tests/data/example-pbmc.nd2,sha256=gqVP7cGePBJk45xRpyXa
|
|
|
24
25
|
arcadia_microscopy_tools/tests/data/example-timelapse.nd2,sha256=KHCubkVWmkRRmJabhfINx_aTwNi5nVUl-IiYNKQsJ9Y,827392
|
|
25
26
|
arcadia_microscopy_tools/tests/data/example-zstack.nd2,sha256=j70DrFhRTwRgzAAJivVM3mCho05YVgsqJwTmPBobRYo,606208
|
|
26
27
|
arcadia_microscopy_tools/tests/data/known-metadata.yml,sha256=_ZIE04MnoLpZtG-6e8ZytYnmAkGh0Q7-2AwSP3v6rQk,1886
|
|
27
|
-
arcadia_microscopy_tools-0.2.
|
|
28
|
-
arcadia_microscopy_tools-0.2.
|
|
29
|
-
arcadia_microscopy_tools-0.2.
|
|
30
|
-
arcadia_microscopy_tools-0.2.
|
|
28
|
+
arcadia_microscopy_tools-0.2.5.dist-info/METADATA,sha256=y2JLq5xdW2Anen9vmLV0LN4nOOrPSacQLvAmj3Q6IzA,5007
|
|
29
|
+
arcadia_microscopy_tools-0.2.5.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
30
|
+
arcadia_microscopy_tools-0.2.5.dist-info/licenses/LICENSE,sha256=5pPae5U0NNXysjBv3vjoquhhoCqTTi1Zh0SehM_IXHI,1072
|
|
31
|
+
arcadia_microscopy_tools-0.2.5.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|