pykrait 0.1.0__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.

Potentially problematic release.


This version of pykrait might be problematic. Click here for more details.

@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: pykrait
3
+ Version: 0.1.0
4
+ Summary: Python Calcium Recording Analysis and Inference Toolbox
5
+ Author-email: Pentabyteman <22663502+Pentabyteman@users.noreply.github.com>
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: aicspylibczi>=3.1.1
10
+ Requires-Dist: cellpose>=4.0.5
11
+ Requires-Dist: dask>=2025.5.1
12
+ Requires-Dist: fsspec>=2022.8.0
13
+ Requires-Dist: imagecodecs>=2025.3.30
14
+ Requires-Dist: lxml>=5.4.0
15
+ Requires-Dist: matplotlib>=3.10.3
16
+ Requires-Dist: numpy<2
17
+ Requires-Dist: ome-types>=0.5.3
18
+ Requires-Dist: ome-zarr>=0.10.2
19
+ Requires-Dist: pip>=25.1.1
20
+ Requires-Dist: pydantic<2
21
+ Requires-Dist: pyyaml>=6.0.2
22
+ Requires-Dist: resource-backed-dask-array>=0.1.0
23
+ Requires-Dist: scikit-image>=0.25.2
24
+ Requires-Dist: scipy>=1.15.3
25
+ Requires-Dist: tifffile<2023.3.15,>=2021.8.30
26
+ Requires-Dist: wrapt>=1.17.2
27
+ Requires-Dist: xarray>2025
28
+ Requires-Dist: xmlschema>=4.0.1
29
+ Requires-Dist: zarr<2.16.0,>=2.6
30
+ Description-Content-Type: text/markdown
31
+
32
+ # pyKrait - Python Calcium Recording Analysis and Inference Toolbox
33
+
34
+ ![Logo](docs/logo.png)
35
+
36
+ ## Overview
37
+
38
+ **pyKrait** is a Python package designed to facilitate automated analysis of calcium imaging data.
39
+
40
+ ## Installation
41
+
42
+ ```bash
43
+ pip install pykrait
44
+ ```
45
+
46
+ ## Documentation
47
+
48
+ See the [docs](docs/) folder for detailed guides and API reference.
49
+
50
+ ## License
51
+
52
+ This project is licensed under the GNU Lesser General Public License (GLP). See the [LICENSE](LICENSE) file for details.
53
+
54
+ ## Contributing
55
+
56
+ Contributions are welcome! Please open issues or submit pull requests.
@@ -0,0 +1,16 @@
1
+ src/pykrait/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
2
+ src/pykrait/io/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ src/pykrait/io/io.py,sha256=XdFX-QoZZmYa1_fR1QctiDGqclVtfjNbay1aitZRiv8,4943
4
+ src/pykrait/pipeline/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
5
+ src/pykrait/pipeline/pipeline.py,sha256=UbQzX-6kyoBbyydlX9ZwanWRen5uoL53brMjpyVMrr8,15933
6
+ src/pykrait/preprocessing/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
7
+ src/pykrait/preprocessing/segmentation.py,sha256=VFa6jNYfTMIE4-FFeO972hWCVJfzmCoQZFKt56anMKM,7049
8
+ src/pykrait/preprocessing/timeseries_extraction.py,sha256=QHGIZiY9sjkVHJzFqHQz6o2cG31OSojVjxvQC3pikEY,15421
9
+ src/pykrait/trace_analysis/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
10
+ src/pykrait/trace_analysis/filtering.py,sha256=6MWumFKfhK9qfeK1Xt-Koj4GYqoGvI_OxDealEPEkJU,4710
11
+ src/pykrait/trace_analysis/oscillations.py,sha256=vBYRbSibe4Rc7DcMeIjwlLF9n2JMCwSooXP3LM1gWUg,5972
12
+ src/pykrait/trace_analysis/peak_analysis.py,sha256=VHDeZXcWkFy9I06U6LQLYhhnmQkuWIcfNI8R9giVTCw,1214
13
+ pykrait-0.1.0.dist-info/METADATA,sha256=LF7h7DnP-vkMwiF5ba1A7-veMhCn7Pfh_P-F-H50hw8,1534
14
+ pykrait-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
15
+ pykrait-0.1.0.dist-info/licenses/LICENSE,sha256=l7NeCNGO7EoJzehxXcQ4Gln1go8n95u6RwwWYNk2OAc,1068
16
+ pykrait-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Pentabyteman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+
File without changes
src/pykrait/io/io.py ADDED
@@ -0,0 +1,117 @@
1
+ import os
2
+ import xml.etree.ElementTree as ET
3
+ import dask.array as da
4
+ import warnings
5
+ import re
6
+ from aicsimageio import AICSImage
7
+
8
+ ALLOWED_IMAGE_EXTENSIONS =[".czi", ".tif", ".tiff"]
9
+
10
+ def load_timelapse_lazy(
11
+ file_path: str,
12
+ ) -> list[da.Array, float, float, float]:
13
+ """Load a timelapse image file lazily using AICSImageIO, returning a Dask array of the image data along with the frame interval and pixel sizes.
14
+
15
+ Currently supports CZI and TIF/TIFF files. The image data is returned as a Dask array with shape (T, C, Y, X) where T is time, C is channels, Y is height, and X is width. The Z dimension is dropped if it has only one slice.
16
+
17
+ :param file_path: input file path to the timelapse image file
18
+ :type file_path: str
19
+ :raises TypeError: if filepath is not a string
20
+ :raises FileNotFoundError: if the file does not exist
21
+ :raises ValueError: if the extension is not supported
22
+ :raises ValueError: if the image does not have the expected 5D shape (TCZYX)
23
+ :raises ValueError: if the image has more than one Z slice and Z cannot be automatically dropped
24
+ :return: returns a list of a Dask array containing the image data, the frame interval in seconds, and pixel sizes in micrometers (Y, X)
25
+ :rtype: list[da.Array, float, float, float]
26
+ """
27
+ if not isinstance(file_path, str):
28
+ raise TypeError("file_path must be a string")
29
+
30
+ if not os.path.exists(file_path):
31
+ raise FileNotFoundError(f"File not found: {file_path}")
32
+
33
+ ext = os.path.splitext(file_path)[-1].lower()
34
+ if ext not in ALLOWED_IMAGE_EXTENSIONS:
35
+ raise ValueError(f"Image to load has the unsupported file format '{ext}'. Supported file formats are: {ALLOWED_IMAGE_EXTENSIONS}")
36
+
37
+ # Load with AICSImage using Dask
38
+ img = AICSImage(file_path, reconstruct_mosaic=False)
39
+
40
+ # extract the relevant metadata
41
+ pixel_sizes = img.physical_pixel_sizes # Units are in micrometers (µm)
42
+ y_um = round(pixel_sizes.Y, 2) if pixel_sizes else None
43
+ x_um = round(pixel_sizes.X, 2) if pixel_sizes else None
44
+
45
+ frame_interval = None
46
+
47
+ # accessing frame interval from tif/tiff meadata
48
+ if ext == ".tiff" or ext == ".tif":
49
+ try:
50
+ match = re.search(r"finterval=([0-9.]+)", img.metadata)
51
+ raw_finterval = float(match.group(1))
52
+ frame_interval = round(raw_finterval, 2) # Output: 1.26
53
+ except Exception as e:
54
+ warnings.warn(
55
+ f"Failed to extract frame interval from TIFF/TIF metadata: {type(e).__name__}: {e}",
56
+ UserWarning
57
+ )
58
+
59
+ # accessing frame interval from czi metadata
60
+ elif ext == ".czi":
61
+ try:
62
+ frame_time_elem = img.metadata.findall(".//FrameTime")
63
+ # select the last FrameTime element if there are multiple and round to 2 decimal places
64
+ frame_interval = round(float(frame_time_elem[-1].text), 2)
65
+ except Exception as e:
66
+ warnings.warn(
67
+ f"Failed to extract frame interval from CZI metadata: {type(e).__name__}: {e}",
68
+ UserWarning
69
+ )
70
+
71
+ # trying to extract frame interval from OME-XML metadata
72
+ else:
73
+ try:
74
+ root = ET.fromstring(img.ome_metadata)
75
+ namespace = {'ome': 'http://www.openmicroscopy.org/Schemas/OME/2016-06'}
76
+ pixels_elem = root.find('.//ome:Pixels', namespaces=namespace)
77
+ if pixels_elem is not None and 'TimeIncrement' in pixels_elem.attrib:
78
+ frame_interval = float(pixels_elem.attrib['TimeIncrement'])
79
+ except Exception as e:
80
+ warnings.warn(
81
+ f"Failed to extract frame interval from OME-XML: {type(e).__name__}: {e}",
82
+ UserWarning
83
+ )
84
+
85
+ dask_img = img.dask_data # TCZYX
86
+
87
+ if dask_img.ndim != 5:
88
+ raise ValueError(f"Expected 5D image (TCZYX), but got shape {dask_img.shape}")
89
+
90
+ T, C, Z, Y, X = dask_img.shape
91
+
92
+ if Z == 1:
93
+ dask_img = dask_img[:, :, 0, :, :] # shape: (T, C, Z, Y, X)
94
+ else:
95
+ raise ValueError(f"Cannot drop Z dimension automatically: Z={Z}. Consider handling it explicitly.")
96
+
97
+ print(f"Returning lazy array of shape {dask_img.shape} (T, C, Y, X)")
98
+ return dask_img, frame_interval, y_um, x_um
99
+
100
+ def get_files_from_folder(
101
+ folder: str,
102
+ extension: str = ".czi",
103
+ ) -> list[str]:
104
+ """returns all files from a folder with specified extensions.
105
+
106
+ :param folder: path to the folder
107
+ :type folder: str
108
+ :param extensions: str of file extension (default is ".czi")
109
+ :type extensions: str
110
+ :return: list of file paths with the specified extensions
111
+ :rtype: list[str]
112
+ """
113
+ if not os.path.isdir(folder):
114
+ raise NotADirectoryError(f"Provided path is not a directory: {folder}")
115
+
116
+ files = [os.path.join(folder, f) for f in os.listdir(folder) if os.path.isfile(os.path.join(folder, f)) and os.path.splitext(f)[-1].lower() == extension]
117
+ return files
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,266 @@
1
+ import pandas as pd
2
+ import numpy as np
3
+ import warnings
4
+ from pykrait.io.io import load_timelapse_lazy, get_files_from_folder
5
+ from pykrait.preprocessing.segmentation import create_cellpose_segmentation, timelapse_projection
6
+ from pykrait.preprocessing.timeseries_extraction import extract_mean_intensities, extract_cell_properties, get_adjacency_matrix
7
+ from pykrait.trace_analysis.filtering import detrend_with_sinc_filter
8
+ from pykrait.trace_analysis.peak_analysis import find_peaks
9
+ from pykrait.trace_analysis.oscillations import find_oscillating_rois_from_peak_series
10
+ from dataclasses import dataclass, asdict
11
+ from typing import Literal
12
+ import os
13
+ import tifffile
14
+
15
+
16
+ @dataclass
17
+ class AnalysisParameters:
18
+ """This dataclass holds the modifiable parameters for calcium video analysis.
19
+
20
+ :var tproj_type: Type of projection accross T-axis to be used for segmentation; 'std' computes standard deviation, 'sum' computes temporal sum, defaults to std.
21
+ :vartype tproj_type: Literal['std', 'sum'], optional
22
+
23
+ :var CLAHE_normalize: whether to apply CLAHE (Contrast Limited Adaptive Histogram Equalization) normalization to enhance image contrast.
24
+ :vartype CLAHE_normalize: bool, optional
25
+
26
+ :var cellpose_model_path: Path to the Cellpose model used for segmentation. Defaults to "cpsam" for the built-in CPSAM model.
27
+ :vartype cellpose_model_path: str, optional
28
+
29
+ :var frame_interval: time interval between consecutive video frames in seconds, defaults to None. If None provided, the frame interval obtained from the image metadata will be used or it will default to 1 second. The code will output a warning if a frame interval is provided which doesn't match with the metadata frame interval.
30
+ :vartype frame_interval: float, optional
31
+
32
+ :var neighbour_tolerance: Maximum distance in micrometers used to detect neighboring cells. Converted to pixels internally.
33
+ :vartype neighbour_tolerance: float, optional
34
+
35
+ :var sinc_filter_window: Window size for the sinc filter used to detrend the signal, specified in seconds. Converted to frame count internally.
36
+ :vartype sinc_filter_window: float, optional
37
+
38
+ :var peak_min_width: Minimum width at half-maximum of a detected peak in seconds. Converted to frames internally.
39
+ :vartype peak_min_width: float, optional
40
+
41
+ :var peak_max_width: Maximum width at half-maximum of a detected peak in seconds. Converted to frames internally.
42
+ :vartype peak_max_width: float, optional
43
+
44
+ :var peak_prominence: Minimum prominence of a detected peak, defined as the height difference between the peak and its surrounding baseline. Arbitrary units.
45
+ :vartype peak_prominence: float, optional
46
+
47
+ :var peak_min_height: Minimum height of a detected peak in arbitrary units.
48
+ :vartype peak_min_height: float, optional
49
+
50
+ :var std_quantile: Quantile used to threshold based on standard deviation for activity detection.
51
+ :vartype std_quantile: float, optional
52
+
53
+ :var cov_quantile: Quantile used to threshold based on the coefficient of variation for activity detection.
54
+ :vartype cov_quantile: float, optional
55
+ """
56
+ tproj_type: Literal['std', 'sum'] = "std"
57
+ CLAHE_normalize: bool = True
58
+ cellpose_model_path: str = "cpsam"
59
+ frame_interval: float = None
60
+ neighbour_tolerance: float = 10
61
+ sinc_filter_window: float = 300
62
+ peak_min_width: float = 1
63
+ peak_max_width: float = 40
64
+ peak_prominence: float = 1000
65
+ peak_min_height: float = 80
66
+ std_quantile: float = 0.01
67
+ cov_quantile: float = 0.01
68
+
69
+
70
+ def to_pandas(self) -> pd.DataFrame:
71
+ """Convert the dataclass to a pandas DataFrame."""
72
+ return pd.DataFrame([asdict(self)])
73
+
74
+ @dataclass
75
+ class AnalysisOutput:
76
+ """
77
+ This dataclass holds the output variables of the pipeline.
78
+ :var filepath: Path to the calcium imaging video file.
79
+ :vartype filepath: str
80
+ :var filename: name of the video file
81
+ :vartype filename: str
82
+ :var frame_interval: time interval between frames in seconds
83
+ :vartype frame_interval: float
84
+ :var pixel_interval_y: pixel size in y direction in micrometers
85
+ :vartype pixel_interval_y: float
86
+ :var pixel_interval_x: pixel size in x direction in micrometers
87
+ :vartype pixel_interval_x: float
88
+ :var number_of_cells: number of cells detected in the video
89
+ :vartype number_of_cells: int
90
+ :var number_of_frames: number of frames in the video
91
+ :vartype number_of_frames: int
92
+ :var normalized_peaks: number of peaks per 100 cells and 10 minutes of recording
93
+ :vartype normalized_peaks: float
94
+ :var cells_four_peaks: number of cells with at least 4 peaks detected
95
+ :vartype cells_four_peaks: int
96
+ :var std_threshold: calculated threshold for the standard deviation in seconds
97
+ :vartype std_threshold: float
98
+ :var random_below_std: number of cells with a standard deviation below the threshold in the random control
99
+ :vartype random_below_std: int
100
+ :var experiment_below_std: number of cells with a standard deviation below the threshold in the recording
101
+ :vartype experiment_below_std: int
102
+ :var cov_threshold: calculated threshold for the coefficient of variation
103
+ :vartype cov_threshold: float
104
+ :var random_below_cov: number of cells with a coefficient of variation below the threshold in the random control
105
+ :vartype random_below_cov: int
106
+ :var experiment_below_cov: number of cells with a coefficient of variation below the threshold in the recording
107
+ :vartype experiment_below_cov: int
108
+ :var random_1st_neighbour_zscore: z-score of synchronicity of the first neighbours in the random control
109
+ :vartype random_1st_neighbour_zscore: float
110
+ :var random_2nd_neighbour_zscore: z-score of synchronicity of the second neighbours in the random control
111
+ :vartype random_2nd_neighbour_zscore: float
112
+ :var random_3rd_neighbour_zscore: z-score of synchronicity of the third neighbours in the random control
113
+ :vartype random_3rd_neighbour_zscore: float
114
+ :var experiment_1st_neighbour_zscore: z-score of synchronicity of the first neighbours in the recording
115
+ :vartype experiment_1st_neighbour_zscore: float
116
+ :var experiment_2nd_neighbour_zscore: z-score of synchronicity of the second neighbours in the recording
117
+ :vartype experiment_2nd_neighbour_zscore: float
118
+ :var experiment_3rd_neighbour_zscore: z-score of synchronicity of the third neighbours in the recording
119
+ :vartype experiment_3rd_neighbour_zscore: float
120
+ :var pykrait_version: version of the pykrait package used for the analysis
121
+ :vartype pykrait_version: str
122
+ :var timestamp: timestamp of the analysis
123
+ :vartype timestamp: np.datetime64
124
+ """
125
+ filepath: str = None
126
+ filename: str = None
127
+ frame_interval: float = None
128
+ pixel_interval_y: float = None
129
+ pixel_interval_x: float = None
130
+ number_of_cells: int = None
131
+ number_of_frames: int = None
132
+ normalized_peaks: float = None
133
+ cells_four_peaks: int = None
134
+ std_threshold: float = None
135
+ random_below_std: int = None
136
+ experiment_below_std: int = None
137
+ cov_threshold: float = None
138
+ random_below_cov: int = None
139
+ experiment_below_cov: int = None
140
+ random_1st_neighbour_zscore: float = None
141
+ random_2nd_neighbour_zscore: float = None
142
+ random_3rd_neighbour_zscore: float = None
143
+ experiment_1st_neighbour_zscore: float = None
144
+ experiment_2nd_neighbour_zscore: float = None
145
+ experiment_3rd_neighbour_zscore: float = None
146
+ pykrait_version: str = None
147
+ timestamp: np.datetime64 = None
148
+
149
+ def to_pandas(self):
150
+ """Convert the dataclass to a pandas DataFrame."""
151
+ return pd.DataFrame([asdict(self)])
152
+
153
+
154
+ class CalciumVideo():
155
+ """
156
+ _summary_
157
+ """
158
+ def __init__(self, video_path:str, params: AnalysisParameters):
159
+ """
160
+ _summary_
161
+
162
+ :param video_path: path to the calcium video
163
+ :type video_path: str
164
+ :param cellpose_model_path: path to the cellpose model, defaults to "cpsam" to use the built-in cellpose model
165
+ :type cellpose_model_path: str, optional
166
+ """
167
+ self.analysis_parameters = params
168
+ self.analysis_output = AnalysisOutput(filepath=video_path)
169
+
170
+ def run(self) -> AnalysisOutput:
171
+ """
172
+ runs the pipeline according to
173
+ """
174
+ self.analysis_output.filename = self.analysis_output.filepath.split("/")[-1]
175
+ print(f"Running analysis on {self.analysis_output.filename}")
176
+ # Create analysis folder in the parent directory of the video file
177
+ parent_dir = os.path.dirname(self.analysis_output.filepath)
178
+ filename_wo_ext = os.path.splitext(self.analysis_output.filename)[0]
179
+ analysis_folder = os.path.join(parent_dir, f"Analysis_{filename_wo_ext}")
180
+ os.makedirs(analysis_folder, exist_ok=True)
181
+
182
+ # load image data lazily
183
+ self.timelapse_data, frame_interval, self.analysis_output.pixel_interval_y, self.analysis_output.pixel_interval_x = load_timelapse_lazy(file_path = self.analysis_output.filepath)
184
+
185
+ if frame_interval is None and self.analysis_parameters.frame_interval is None:
186
+ warnings.warn("No frame interval provided in the analysis parameters, and no frame interval could be inferred from the metadata. Defaulting to 1 second.")
187
+ self.analysis_output.frame_interval = 1 # Default to 1 second if not provided
188
+ elif self.analysis_parameters.frame_interval is None and frame_interval is not None:
189
+ self.analysis_output.frame_interval = frame_interval
190
+ elif frame_interval is not None and self.analysis_parameters.frame_interval != frame_interval:
191
+ warnings.warn(f"Provided frame_interval ({self.analysis_parameters.frame_interval}) does not match inferred frame_interval ({frame_interval}) from metadata. Using provided value.")
192
+
193
+ # perform the zproj
194
+ self.zproj = timelapse_projection(self.timelapse_data, method=self.analysis_parameters.tproj_type, normalize=self.analysis_parameters.CLAHE_normalize, verbose=True)
195
+ tifffile.imwrite(os.path.join(analysis_folder, f"{filename_wo_ext}_zproj.ome.tif"), self.zproj.astype(np.uint16), metadata={'axes': 'CYX'}, compression="zlib")
196
+
197
+ # create cellpose segmentation
198
+ self.masks = create_cellpose_segmentation(self.zproj, cellpose_model_path=self.analysis_parameters.cellpose_model_path)
199
+ tifffile.imwrite(os.path.join(analysis_folder, f"{filename_wo_ext}_cp_masks.ome.tif"), self.masks.astype(np.uint16), metadata={'axes': 'YX'}, compression="zlib")
200
+
201
+ # extracts the mean intensities of the masks
202
+ self.mean_intensities = extract_mean_intensities(self.timelapse_data, self.masks)
203
+ self.analysis_output.number_of_frames, self.analysis_output.number_of_cells = self.mean_intensities.shape
204
+
205
+ self.cell_properties = extract_cell_properties(self.masks)
206
+
207
+ # convert seconds and µm to frames and pixels
208
+ neighbour_tolerance_pixels = int(self.analysis_parameters.neighbour_tolerance / self.analysis_output.pixel_interval_x) # convert micrometers to pixels
209
+ peak_min_width_frames = int(self.analysis_parameters.peak_min_width / self.analysis_output.frame_interval) # convert seconds to frames
210
+ peak_max_width_frames = int(self.analysis_parameters.peak_max_width / self.analysis_output.frame_interval) # convert seconds to frames
211
+
212
+ # checking if the parameters are valid
213
+ if neighbour_tolerance_pixels < 1:
214
+ warnings.warn(f"Neighbour tolerance of {neighbour_tolerance_pixels} pixels is too small, setting to 1 pixel.")
215
+ neighbour_tolerance_pixels = 1
216
+ if peak_min_width_frames < 1:
217
+ warnings.warn(f"Peak minimum width of {peak_min_width_frames} frames is too small, setting to 1 frame.")
218
+ peak_min_width_frames = 1
219
+ if peak_max_width_frames <= peak_min_width_frames:
220
+ warnings.warn(f"Peak maximum width of {peak_max_width_frames} frames is smaller or equal than peak minimum width of {peak_min_width_frames} seconds, setting to peak minimum width + 1.")
221
+ peak_max_width_frames = peak_min_width_frames + 1
222
+
223
+ self.adjacency_matrix = get_adjacency_matrix(self.masks, neighbour_tolerance=neighbour_tolerance_pixels)
224
+
225
+ self.detrended_timeseries = detrend_with_sinc_filter(signals=self.mean_intensities,
226
+ cutoff_period=self.analysis_parameters.sinc_filter_window,
227
+ sampling_interval=self.analysis_output.frame_interval)
228
+
229
+ self.peak_series = find_peaks(peak_min_width=peak_min_width_frames,
230
+ peak_max_width=peak_max_width_frames,
231
+ peak_prominence=self.analysis_parameters.peak_prominence,
232
+ peak_min_height=self.analysis_parameters.peak_min_height,
233
+ detrended_timeseries=self.detrended_timeseries)
234
+
235
+ peaks_per_100c_per_10_min = (np.sum(self.peak_series) / (self.analysis_output.number_of_cells / 100)) / ((self.analysis_output.number_of_frames * self.analysis_output.frame_interval) / 600) # normalize to 100 cells and 10 minutes of recording
236
+ self.analysis_output.normalized_peaks = round(peaks_per_100c_per_10_min, 2)
237
+
238
+ self.analysis_output.cells_four_peaks = np.sum(np.sum(self.peak_series, axis=0) >= 4)
239
+
240
+ self.analysis_output.std_threshold, self.analysis_output.experiment_below_std, self.analysis_output.random_below_std, self.analysis_output.cov_threshold, self.analysis_output.experiment_below_cov, self.analysis_output.random_below_cov = find_oscillating_rois_from_peak_series(self.peak_series,
241
+ self.analysis_parameters.std_quantile,
242
+ self.analysis_parameters.cov_quantile,
243
+ self.analysis_output.frame_interval,
244
+ n_iter=100)
245
+ # saving the parameters and output to the analysis folder
246
+ self.analysis_output.to_pandas().to_csv(os.path.join(analysis_folder, f"{filename_wo_ext}_analysis_output.csv"), index=False)
247
+ self.analysis_parameters.to_pandas().to_csv(os.path.join(analysis_folder, f"{filename_wo_ext}_analysis_parameters.csv"), index=False)
248
+ return self.analysis_output
249
+
250
+
251
+ class BatchExperiment:
252
+ def __init__(self, folder: str, params: AnalysisParameters, extension:str = ".czi"):
253
+ files = get_files_from_folder(folder, extension=extension)
254
+ self.experiments = [CalciumVideo(video_path=video_path, params=params) for video_path in files]
255
+ self.results = pd.DataFrame()
256
+
257
+ def run(self):
258
+
259
+ for experiment in self.experiments:
260
+ result = experiment.run().to_pandas()
261
+ self.results = pd.concat([self.results, result], ignore_index=True)
262
+
263
+
264
+
265
+
266
+
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,176 @@
1
+ import dask.array as da
2
+ import numpy as np
3
+ import warnings
4
+ import os
5
+ from cellpose import models
6
+ from tqdm.dask import TqdmCallback
7
+ from typing import Literal
8
+ from skimage import exposure
9
+
10
+ def _scale_to_uint16(arr: np.ndarray) -> np.ndarray:
11
+ """Wraps skimage's rescale_intensity to convert an array to 16-bit unsigned integer format.
12
+
13
+ :param arr: input array
14
+ :type arr: np.ndarray
15
+ :return: returns the input array scaled to 16-bit unsigned integer format.
16
+ :rtype: np.ndarray
17
+ """
18
+ return exposure.rescale_intensity(arr, out_range='uint16').astype(np.uint16)
19
+
20
+ def _fast_time_sum(timelapse: da.Array) -> np.ndarray:
21
+ T, C, Y, X = timelapse.shape
22
+
23
+ # Rechunk T so blocks are as large as memory allows
24
+ rechunked = timelapse.rechunk({0: "auto"})
25
+
26
+ # Reduce each T-chunk to (1, C, Y, X)
27
+ partial_sums = rechunked.map_blocks(
28
+ lambda block: block.sum(axis=0, keepdims=True),
29
+ dtype=timelapse.dtype,
30
+ chunks=(1, C, Y, X)
31
+ )
32
+
33
+ # Sum across all (1, C, Y, X) blocks
34
+ final_sum = partial_sums.sum(axis=0)
35
+
36
+ return final_sum
37
+
38
+ def _fast_time_std(timelapse: da.Array) -> np.ndarray:
39
+ """
40
+ Efficiently computes standard deviation over the time axis (axis=0) of a T,C,Y,X dask array.
41
+ Uses Dask-native operations for parallel execution and memory efficiency.
42
+
43
+ :param timelapse: Dask array with shape (T, C, Y, X)
44
+ :return: Dask array of shape (C, Y, X) with std computed over T
45
+ """
46
+ T, C, Y, X = timelapse.shape
47
+
48
+ # Rechunk T to optimize chunk sizes
49
+ rechunked = timelapse.rechunk({0: "auto"})
50
+
51
+ # First pass: compute mean per block and total sum
52
+ partial_sums = rechunked.map_blocks(
53
+ lambda block: block.sum(axis=0, keepdims=True),
54
+ dtype=timelapse.dtype,
55
+ chunks=(1, C, Y, X)
56
+ )
57
+ total_sum = partial_sums.sum(axis=0)
58
+ mean = total_sum / T
59
+
60
+ # Second pass: compute squared differences from mean per block
61
+ def squared_diff_block(block, mean):
62
+ return ((block - mean) ** 2).sum(axis=0, keepdims=True)
63
+
64
+ # Broadcast mean to match chunk shapes
65
+ partial_squared_diffs = rechunked.map_blocks(
66
+ squared_diff_block,
67
+ mean,
68
+ dtype=timelapse.dtype,
69
+ chunks=(1, C, Y, X)
70
+ )
71
+
72
+ # Sum all squared diffs and divide by T (or T-1 for sample std)
73
+ total_squared_diff = partial_squared_diffs.sum(axis=0)
74
+ std = da.sqrt(total_squared_diff / T)
75
+
76
+ return std
77
+
78
+ def timelapse_projection(lazy_timelapse: da.array, normalize:bool=True, method: Literal["std", "sum"] = "std", verbose: bool = True) -> np.ndarray:
79
+ """
80
+ Computes a T-projection of a timelapse to be used for cell segmentation.
81
+
82
+ :param lazy_timelapse: 4D dask array representing the timelapse, with shape order "TCYX".
83
+ :type lazy_timelapse: da.array
84
+ :param normalize: If True, applies CLAHE normalization.
85
+ :type normalize: bool, optional
86
+ :param method: method to be used for the projection, either "std" or "sum" for sum, defaults to "std"
87
+ :type method: Literal["std", "sum"], optional, optional
88
+ :param verbose: whether to output progress bar, defaults to True
89
+ :type verbose: bool, optional
90
+ :raises TypeError: if input is not a dask array
91
+ :raises ValueError: if input does not have 4 dimensions
92
+ :raises ValueError: if unknown T-proj method
93
+ :return: 3D "CYX" numpy array containing the standard deviation for each pixel across all frames in 16-bit.
94
+ :rtype: np.ndarray
95
+ """
96
+
97
+ # Check if the input is a 4D dask array
98
+ if not isinstance(lazy_timelapse, da.Array):
99
+ raise TypeError("Input must be a dask array.")
100
+
101
+ if lazy_timelapse.ndim != 4:
102
+ raise ValueError("Input timelapse must be a 4D array with shape TCYX.")
103
+
104
+ T, C, Y, X = lazy_timelapse.shape
105
+ if T < C or T > Y or T > X or C > Y or C > X:
106
+ warnings.warn(f"Input array shape of {lazy_timelapse.shape} is unusual. Is it in TCYX order?", UserWarning)
107
+
108
+ #lazy_timelapse = lazy_timelapse.rechunk({0: -1, 2: "auto", 3: "auto"})
109
+ if method == "std":
110
+ if verbose:
111
+ with TqdmCallback(desc="Computing STD projection"):
112
+ t_proj = _fast_time_std(lazy_timelapse).compute(scheduler='threads')
113
+ else:
114
+ t_proj = _fast_time_std(lazy_timelapse).compute(scheduler='threads')
115
+ t_proj = np.nan_to_num(t_proj, nan=0.0) # Replace NaNs with 0
116
+ t_proj = _scale_to_uint16(t_proj) # Scale to uint16
117
+ elif method == "sum":
118
+ if verbose:
119
+ with TqdmCallback(desc="Computing SUM projection"):
120
+ t_proj = _fast_time_sum(lazy_timelapse).compute(scheduler='threads')
121
+ else:
122
+ t_proj = _fast_time_sum(lazy_timelapse).compute(scheduler='threads')
123
+ else:
124
+ raise ValueError("T-Projection Method must be either 'std' or 'sum'.")
125
+
126
+ if normalize:
127
+ # Enhance contrast per channel (C, Y, X)
128
+ enhanced = np.empty_like(t_proj)
129
+ for c in range(t_proj.shape[0]):
130
+ enhanced[c] = _scale_to_uint16(exposure.equalize_adapthist(t_proj[c]))
131
+ else:
132
+ enhanced = _scale_to_uint16(t_proj)
133
+
134
+ return enhanced
135
+
136
+ def create_cellpose_segmentation(image:np.ndarray, cellpose_model_path:str) -> np.ndarray:
137
+ """
138
+ Wrapper function to create a label image from an input image using a prespecified cellpose model.
139
+
140
+ :param image: image to be processed with cellpose.
141
+ :type image: ndarray
142
+ :param cellpose_model_path: path to the cellpose model file. Can also be cpsam if using cellpose-4 and the standard builtin model.
143
+ :type cellpose_model_path: str
144
+
145
+ :return label_img: The generated label image.
146
+ :rtype: np.ndarray
147
+
148
+ :raises FileNotFoundError: If the Cellpose model path does not exist.
149
+ :raises ValueError: If the input std image is not a 2D array.
150
+ :raises RuntimeError: If the Cellpose model fails to load with both GPU and CPU backends.
151
+ """
152
+
153
+ # check if the model path exists
154
+ if cellpose_model_path != "cpsam" and not os.path.isfile(cellpose_model_path):
155
+ raise FileNotFoundError("Cellpose model not found at {}".format(cellpose_model_path))
156
+ #TODO: check if the model was trained using the same cellpose version
157
+
158
+ if image.ndim > 3:
159
+ raise ValueError("Input image must be a 2D (YX) or 3D (CYX) array but got shape {}".format(image.shape))
160
+
161
+ # setting up the cellpose segmentation, trying GPU first and falling back to CPU if it fails
162
+ try:
163
+ model = models.CellposeModel(gpu=True, pretrained_model = cellpose_model_path)
164
+ except Exception as e:
165
+ print(f"Error loading Cellpose model wiht GPU backend due to: {e}")
166
+ try:
167
+ model = models.CellposeModel(gpu=False, pretrained_model=cellpose_model_path)
168
+ except Exception as e:
169
+ raise RuntimeError(f"Failed to load Cellpose model with CPU backend due to: {e}")
170
+
171
+ mask, _, _ = model.eval(image)
172
+
173
+ if np.max(mask) == 0:
174
+ warnings.warn("The generated mask contains only zeros. No cells were detected.", UserWarning)
175
+
176
+ return mask
@@ -0,0 +1,329 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ import warnings
4
+ import dask.array as da
5
+ from dask import delayed, compute
6
+ from tqdm.dask import TqdmCallback
7
+ from scipy.sparse.csgraph import floyd_warshall
8
+ from scipy.spatial import KDTree
9
+ from scipy.sparse import lil_matrix
10
+ from functools import lru_cache
11
+ from skimage.measure import regionprops_table, regionprops
12
+ from skimage.segmentation import find_boundaries
13
+ from scipy.spatial.distance import cdist
14
+
15
+ def _legacy_extract_mean_intensities(numpy_timelapse:np.ndarray, masks:np.ndarray) -> np.ndarray:
16
+ """
17
+ This function computes the mean intensity of each cell across all frames in a timelapse, given a label image that identifies
18
+ the regions. It returns a 2D numpy array where each row corresponds to a cell and each column corresponds to a frame, containing the
19
+ mean intensity of each cell per frame.
20
+
21
+ Note: If the input timelapse has multiple channels, only the first channel (C=0) will be used for intensity calculations.
22
+
23
+ It expectes the 4D-numpy array with order "TCYX" to be fully loaded into memory. Loading the dataframe into memory and running this function takes more memory and longer than the lazy version.
24
+
25
+ :param numpy_timelapse: A 4D-numpy array with order "TCYX", fully loaded into memory.
26
+ :type numpy_timelapse: np.ndarray
27
+ :param masks: A 2D numpy array containing labels for each region as label image.
28
+ :type masks: np.ndarray
29
+
30
+ :return: a cell x time numpy ndarray containing the mean intensity of each cell per frame of shape (n_cells, T).
31
+ :rtype: np.ndarray
32
+ """
33
+ if not isinstance(numpy_timelapse, np.ndarray):
34
+ raise TypeError("Input must be a numpy array.")
35
+ if numpy_timelapse.ndim != 4:
36
+ raise ValueError("Input timelapse must be a 4D array with shape TCYX.")
37
+ if masks.ndim != 2:
38
+ raise ValueError("Masks must be a 2D array representing the label image.")
39
+ if masks.shape[0] != numpy_timelapse.shape[2] or masks.shape[1] != numpy_timelapse.shape[3]:
40
+ raise ValueError("Masks shape (Y,X: {}, {}) must match the spatial dimensions of the timelapse (Y,X: {}, {}).".format(
41
+ masks.shape[0], masks.shape[1], numpy_timelapse.shape[2], numpy_timelapse.shape[3]))
42
+
43
+ # check that the timelapse is in TCYX order
44
+ T, C, Y, X = numpy_timelapse.shape
45
+ if T < C or T > Y or T > X or C > Y or C > X:
46
+ warnings.warn(f"Input array shape of {numpy_timelapse.shape} is unusual. Is it in TCYX order?", UserWarning)
47
+ if C > 1:
48
+ warnings.warn("Input timelapse has multiple channels. Only the first channel (C=0) will be used for intensity calculations.", UserWarning)
49
+
50
+ video = numpy_timelapse[:,0,:,:].squeeze() # Ensure it's 3D (T, Y, X)
51
+
52
+ roi_properties = regionprops_table(label_image=masks,
53
+ intensity_image=video.transpose(1,2,0),
54
+ properties=("label", "intensity_mean"),
55
+ separator=",")
56
+ intensity_array = np.array(list(roi_properties.values())[1:])
57
+
58
+ return intensity_array
59
+
60
+
61
+ def _compute_mean_for_frame(frame:np.ndarray, masks:np.array) -> np.ndarray:
62
+ """Helper function to compute the mean intensity of each cell for a single frame.
63
+ It uses regionprops_table from skimage.measure to compute the mean intensities.
64
+
65
+ :param frame: A 4D dask array with order "TCYX", fully loaded into memory.
66
+ :type frame: np.ndarray
67
+ :param masks: The label image for the frame.
68
+ :type masks: np.ndarray
69
+
70
+ :return: a numpy array containing the mean intensity of each cell for the specified timepoint.
71
+ :rtype: np.ndarray
72
+ """
73
+ props = regionprops_table(label_image=masks, intensity_image=frame, properties=("label", "intensity_mean"))
74
+ return np.array(props["intensity_mean"])
75
+
76
+
77
+ def extract_mean_intensities(dask_timelapse:da.Array, masks:np.ndarray, verbose:bool=True) -> np.ndarray:
78
+ """
79
+ This function computes the mean intensity of each cell across all frames in a timelapse, given a label image that identifies
80
+ the regions.
81
+
82
+ Note: If the input timelapse has multiple channels, only the first channel (C=0) will be used for intensity calculations.
83
+ It uses dask to lazily compute the mean intensities for each ROI and timepoint and does not load the entire timelapse into memory.
84
+
85
+ :param dask_timelapse: A 4D dask array with order "TCYX", fully loaded into memory.
86
+ :type dask_timelapse: da.Array
87
+ :param masks: A 2D numpy array containing labels for each region as label image.
88
+ :type masks: np.ndarray
89
+ :param verbose: if True, shows the progress bar for the computation.
90
+ :type verbose: bool
91
+ :raises TypeError: If the input is not a dask array.
92
+ :raises ValueError: If the input timelapse is not a 4D array or if the masks do not match the spatial dimensions of the timelapse.
93
+
94
+ :return: a T x n_rois numpy array containing the mean intensity of each cell per frame
95
+ :rtype: np.ndarray
96
+ """
97
+ if not isinstance(dask_timelapse, da.Array):
98
+ raise TypeError("Input must be a dask array.")
99
+ if dask_timelapse.ndim != 4:
100
+ raise ValueError("Input timelapse must be a 4D array with shape TCYX and not shape {}.".format(dask_timelapse.shape))
101
+ if masks.ndim != 2:
102
+ raise ValueError("Masks must be a 2D array representing the label image and not shape {}.",format(masks.shape))
103
+ if masks.shape[0] != dask_timelapse.shape[2] or masks.shape[1] != dask_timelapse.shape[3]:
104
+ raise ValueError("Masks shape (Y,X: {}, {}) must match the spatial dimensions of the timelapse (Y,X: {}, {}).".format(
105
+ masks.shape[0], masks.shape[1], dask_timelapse.shape[2], dask_timelapse.shape[3]))
106
+ # check that the timelapse is in TCYX order
107
+ T, C, Y, X = dask_timelapse.shape
108
+ if T < C or T > Y or T > X or C > Y or C > X:
109
+ warnings.warn(f"Input array shape of {dask_timelapse.shape} is unusual. Is it in TCYX order?", UserWarning)
110
+ if C > 1:
111
+ warnings.warn("Input timelapse has multiple channels. Only the first channel (C=0) will be used for intensity calculations.", UserWarning)
112
+
113
+ dask_timelapse = dask_timelapse.rechunk({0: 1, 1: 1})
114
+ n_rois = np.max(masks)
115
+
116
+ dask_frames = dask_timelapse[:, 0, :, :] # shape: (T, Y, X)
117
+
118
+ def block_func(block): # block has shape (1, Y, X)
119
+ frame = block[0] # shape (Y, X)
120
+ means = _compute_mean_for_frame(frame, masks) # shape (n_rois,)
121
+ return means[np.newaxis, :] # shape (1, n_rois)
122
+
123
+ mean_dask = da.map_blocks(
124
+ block_func,
125
+ dask_frames,
126
+ dtype=np.float32,
127
+ chunks=(1, n_rois), # Must match output shape per block
128
+ drop_axis=(1, 2), # Drop Y, X axes
129
+ new_axis=1 # Add n_rois axis
130
+ )
131
+
132
+ if verbose:
133
+ with TqdmCallback(desc="Computing mean intensities"):
134
+ mean_intensities = mean_dask.compute(scheduler="threads")
135
+ else:
136
+ mean_intensities = mean_dask.compute(scheduler="threads")
137
+
138
+ return mean_intensities # shape: (T, n_rois)
139
+
140
+
141
+ def extract_cell_properties(masks:np.ndarray) -> tuple:
142
+ """
143
+ Returns cell properties (area, axis lengths, perimeter) and positions (centroid coordinates) of each cell in the label image.
144
+
145
+
146
+ :param masks: input label image
147
+ :type masks: np.ndarray
148
+
149
+ :return cell_properties: pandas dataframe of cell properties
150
+ :rtype cell_properties: pd.DataFrame
151
+ :return cell_positions: (nd.array of size len(roi) x 2, np.uint16): pixel positions of every roi
152
+ :rtype cell_positions: np.ndarray
153
+ """
154
+
155
+ cell_properties = regionprops_table(label_image=masks,
156
+ intensity_image=masks,
157
+ properties=("label", "centroid",
158
+ "intensity_max", "area",
159
+ "axis_major_length",
160
+ "axis_minor_length",
161
+ "perimeter"),
162
+ separator=",")
163
+ # to get the 0-indexed index array of labels
164
+ cell_indices = cell_properties['intensity_max'].astype(int) - 1
165
+
166
+ cell_properties['label'] = cell_indices
167
+ cell_properties = pd.DataFrame(cell_properties)
168
+
169
+ ypos = cell_properties['centroid,0']
170
+ xpos = cell_properties['centroid,1']
171
+
172
+ # maximum pixel value of masks is number of labels/cells
173
+ cell_positions = np.zeros((masks.max(), 2), dtype=np.uint16)
174
+ cell_positions[:, 0] = xpos[cell_indices].astype(np.uint16)
175
+ cell_positions[:, 1] = ypos[cell_indices].astype(np.uint16)
176
+
177
+ return cell_properties, cell_positions
178
+
179
+
180
+ def _legacy_get_adjacency_matrix(masks: np.ndarray, neighbour_tolerance: int) -> tuple[np.ndarray, np.ndarray]:
181
+ """Computes the adjacency matrix and shortest path matrix for the given mask with ROIs using pairwise distances between all boundary pixels.
182
+
183
+ :param masks: _description_
184
+ :type masks: np.ndarray
185
+ :param neighbour_tolerance: _description_
186
+ :type neighbour_tolerance: int
187
+ :return: _description_
188
+ :rtype: tuple[np.ndarray, np.ndarray]
189
+ """
190
+ n_labels = masks.max()
191
+ adjacency_matrix = np.zeros((n_labels, n_labels), dtype=np.uint8)
192
+
193
+ props = regionprops(masks)
194
+ all_boundary_pixels = []
195
+ pixel_labels = []
196
+
197
+ for region in props:
198
+ label = region.label
199
+ region_mask = (masks == label)
200
+ boundary = find_boundaries(region_mask, mode="outer")
201
+ coords = np.argwhere(boundary)
202
+ all_boundary_pixels.append(coords)
203
+ pixel_labels.extend([label - 1] * len(coords))
204
+
205
+ if len(all_boundary_pixels) == 0:
206
+ shortest_path_matrix = np.triu(floyd_warshall(adjacency_matrix), k=1)
207
+ return adjacency_matrix, shortest_path_matrix
208
+
209
+ all_boundary_pixels = np.vstack(all_boundary_pixels)
210
+ pixel_labels = np.array(pixel_labels)
211
+
212
+ tree = KDTree(all_boundary_pixels)
213
+ pairs = tree.query_pairs(r=neighbour_tolerance)
214
+
215
+ for idx1, idx2 in pairs:
216
+ label1 = pixel_labels[idx1]
217
+ label2 = pixel_labels[idx2]
218
+ if label1 != label2:
219
+ adjacency_matrix[label1, label2] = 1
220
+ adjacency_matrix[label2, label1] = 1
221
+
222
+ shortest_path_matrix = np.triu(floyd_warshall(adjacency_matrix), k=1)
223
+ return adjacency_matrix, shortest_path_matrix
224
+
225
+
226
+ @lru_cache(maxsize=32)
227
+ def _generate_distance_kernel(neighbour_tolerance: int) -> np.ndarray:
228
+ """Generates a circular distance kernel for the given neighbour tolerance to identify pixels within bounds
229
+
230
+ :param neighbour_tolerance: integer defining the maximum distance between two pixels to be considered neighbours.
231
+ :type neighbour_tolerance: int
232
+ :return: returns a 2D numpy array of shape (2 * neighbour_tolerance + 1, 2 * neighbour_tolerance + 1) where each pixel is either 0 or 1, indicating whether the pixel is within the neighbour tolerance.
233
+ :rtype: np.ndarray
234
+ """
235
+ size = 2 * neighbour_tolerance + 1
236
+ coords = np.indices((size, size)).reshape(2, -1).T
237
+ center = np.array([[neighbour_tolerance, neighbour_tolerance]])
238
+ distances = cdist(center, coords).reshape(size, size)
239
+ return (distances <= neighbour_tolerance).astype(np.uint8)
240
+
241
+
242
+ def _process_region(region, masks: np.ndarray, circular_kernel: np.ndarray, kernel_radius:int) -> list:
243
+ """Helper function to process each region and find pairs of adjacent masks
244
+
245
+ :param region: region object from skimage.measure.regionprops
246
+ :type region: _type_
247
+ :param masks: label image containing the ROIs for each cell
248
+ :type masks: np.ndarray
249
+ :param circular_kernel: kernel used to identify pixels within the neighbour tolerance
250
+ :type circular_kernel: np.ndarray
251
+ :param kernel_radius: _description_
252
+ :type kernel_radius: int
253
+ :return: returns a list of tuples, where each tuple contains the indices of two adjacent regions (i, j) such that i < j.
254
+ :rtype: list
255
+ """
256
+ h, w = masks.shape
257
+ label = region.label
258
+ region_mask = masks == label
259
+ boundary = find_boundaries(region_mask, mode='outer')
260
+ ys, xs = np.argwhere(boundary).T
261
+
262
+ found_pairs = set()
263
+
264
+ for y, x in zip(ys, xs):
265
+ y_start = max(0, y - kernel_radius)
266
+ y_end = min(h, y + kernel_radius + 1)
267
+ x_start = max(0, x - kernel_radius)
268
+ x_end = min(w, x + kernel_radius + 1)
269
+
270
+ patch = masks[y_start:y_end, x_start:x_end]
271
+
272
+ ky_start = kernel_radius - (y - y_start)
273
+ ky_end = ky_start + (y_end - y_start)
274
+ kx_start = kernel_radius - (x - x_start)
275
+ kx_end = kx_start + (x_end - x_start)
276
+
277
+ mask_patch = circular_kernel[ky_start:ky_end, kx_start:kx_end]
278
+ visible_labels = np.unique(patch[mask_patch > 0])
279
+
280
+ for neighbor_label in visible_labels:
281
+ if neighbor_label == 0 or neighbor_label == label:
282
+ continue
283
+ i, j = label - 1, neighbor_label - 1
284
+ found_pairs.add((min(i, j), max(i, j))) # Sort to avoid duplicates
285
+
286
+ return list(found_pairs)
287
+
288
+
289
+ def get_adjacency_matrix(masks: np.ndarray, neighbour_tolerance: int, verbose: bool=True) -> tuple[np.ndarray, np.ndarray]:
290
+ """Computes the adjacency matrix and shortest path matrix for ROIs within the neighbour_tolerance in the given mask.
291
+
292
+ :param masks: 2D label image where the value of every ROI is equal to its index. A pixel value of 0 indicates the image background.
293
+ :type masks: np.ndarray
294
+ :param neighbour_tolerance: maximum distance between two ROIs to still be considered neighbours, in pixels.
295
+ :type neighbour_tolerance: int
296
+ :param verbose: if true, prints a progress bar for adjacency matrix construction, defaults to True
297
+ :type verbose: bool, optional
298
+ :return: returns a ROI x ROI adjacency matrix, where the element at (i, j) is 1 if ROI i and ROI j are neighbours, and 0 otherwise. Also returns a shortest path matrix, where the element at (i, j) is the length of the shortest path between ROI i and ROI j.
299
+ :rtype: tuple[np.ndarray, np.ndarray]
300
+
301
+ In benchmarking, this function is about 25x faster than the legacy version _legacy_get_adjacency_matrix, which uses cdist to compute the distances between all pairs of ROIs. However, they do not produce exactly the same adjacency matrix.
302
+ _legacy_get_adjacency_matrix identifies ca. 1.7% more pairs of adjacent ROIs than this function, which is likely due to edge cases around the kernel radius and the way boundaries are defined here.
303
+ """
304
+ n_labels = masks.max()
305
+ kernel_radius = neighbour_tolerance
306
+ circular_mask = _generate_distance_kernel(neighbour_tolerance)
307
+
308
+ props = regionprops(masks)
309
+
310
+ tasks = [
311
+ delayed(_process_region)(region, masks, circular_mask, kernel_radius)
312
+ for region in props
313
+ ]
314
+
315
+ # If verbose is True, use TqdmCallback to show progress
316
+ if verbose:
317
+ with TqdmCallback(desc="Computing adjacency matrix"):
318
+ region_pairs = compute(*tasks)
319
+ else:
320
+ region_pairs = compute(*tasks)
321
+
322
+ adjacency_matrix = lil_matrix((n_labels, n_labels), dtype=np.uint8)
323
+ for pairs in region_pairs:
324
+ for i, j in pairs:
325
+ adjacency_matrix[i, j] = 1
326
+ adjacency_matrix[j, i] = 1
327
+
328
+ shortest_path_matrix = floyd_warshall(adjacency_matrix, directed=False, unweighted=True)
329
+ return adjacency_matrix.toarray(), np.triu(shortest_path_matrix, k=1)
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,114 @@
1
+ import numpy as np
2
+ from math import pi
3
+ from scipy.signal import fftconvolve
4
+ from scipy.ndimage import gaussian_filter1d
5
+
6
+ def _blackman_sinc_filter_kernel(kernel_window: int, cutoff_freq: float) -> list[float]:
7
+ """Generates a Blackman windowed sinc filter kernel
8
+
9
+ :param kernel_window: window size for the kernel, must be even
10
+ :type kernel_window: int
11
+ :param cutoff_freq: cut-off frequency, oscillations of lower frequencies will be blocked
12
+ :type cutoff_freq: float
13
+ :return: returns the filter kernel as a list of floats
14
+ :rtype: list
15
+
16
+ See the pyBOAT paper (https://www.biorxiv.org/content/10.1101/2020.04.29.067744v2) and Chapter 16 of the The Scientist and Engineer's Guide to Digital Signal Processing (https://www.dspguide.com/ch16/1.htm) for more details.
17
+ """
18
+ if kernel_window % 2 != 0:
19
+ raise ValueError("Filter kernel window must be even")
20
+
21
+ kernel = np.zeros(kernel_window + 1, dtype=float)
22
+ midpoint = kernel_window / 2
23
+
24
+ for n in range(kernel_window + 1):
25
+ if n == midpoint:
26
+ val = 2 * pi * cutoff_freq
27
+ else:
28
+ val = np.sin(2 * pi * cutoff_freq * (n - midpoint)) / (n - midpoint)
29
+ # Apply Blackman window
30
+ val *= (0.42 - 0.5 * np.cos(2 * pi * n / kernel_window) + 0.08 * np.cos(4 * pi * n / kernel_window))
31
+ kernel[n] = val
32
+
33
+ kernel /= np.sum(kernel)
34
+ return kernel
35
+
36
+ def _convolve_signals_with_kernel(signals: np.ndarray, filter_kernel: list) -> np.ndarray:
37
+ """Convolve a 2D array of signals with a filter kernel using FFT convolution.
38
+
39
+ :param signals: 2D numpy array of shape (T x n_rois)
40
+ :type signals: np.ndarray
41
+ :param filter_kernel: kernel to convolve with, must be odd length
42
+ :type filter_kernel: list
43
+ :raises ValueError: raises a ValueError if the filter kernel length is not odd
44
+ :return: 2D numpy array of convolved signals in the shape (T x n_rois)
45
+ :rtype: np.ndarray
46
+ """
47
+ kernel_length = len(filter_kernel)
48
+ if kernel_length % 2 == 0:
49
+ raise ValueError("Filter kernel length must be odd")
50
+
51
+ # Convert to 1D array if needed
52
+ filter_kernel = np.asarray(filter_kernel).flatten()
53
+
54
+ # Pad signals along time axis (axis=0)
55
+ left_padding = signals[kernel_length - 1:0:-1, :]
56
+ right_padding = signals[-2:-kernel_length - 1:-1, :]
57
+ padded_signals = np.concatenate([left_padding, signals, right_padding], axis=0)
58
+
59
+ # Apply convolution along axis=0 (time)
60
+ convolved = fftconvolve(padded_signals, filter_kernel[:, None], mode='valid', axes=0)
61
+
62
+ # Extract center portion to match input shape
63
+ start = (kernel_length - 1) // 2
64
+ end = start + signals.shape[0]
65
+ result = convolved[start:end, :]
66
+
67
+ return result
68
+
69
+ def detrend_with_sinc_filter(
70
+ signals: np.ndarray,
71
+ cutoff_period: float,
72
+ sampling_interval: float
73
+ ) -> np.ndarray:
74
+ """Applies a Blackman-windowd sinc filter to a 2D array of signals to detrend them. Frequencies lower than the cutoff frequency will be blocked, while higher frequencies will pass through unaffected.
75
+
76
+ See the pyBOAT paper (https://www.biorxiv.org/content/10.1101/2020.04.29.067744v2) and Chapter 16 of the The Scientist and Engineer's Guide to Digital Signal Processing (https://www.dspguide.com/ch16/1.htm) for more details.
77
+
78
+ :param signals: 2D numpy array of shape (T x n_rois)
79
+ :type signals: np.ndarray
80
+ :param cutoff_period: cutoff period in [time units], lower frequencies will be blocked
81
+ :type cutoff_period: float
82
+ :param sampling_interval: sampling interval in [time units] of the signals
83
+ :type sampling_interval: float
84
+ :return: returns the detrended signals as a 2D numpy array of shape (T x n_rois)
85
+ :rtype: np.ndarray
86
+ """
87
+ signal_length, _ = signals.shape
88
+
89
+ kernel_window = signal_length - 1
90
+ if kernel_window % 2 != 0:
91
+ kernel_window -= 1 # Make kernel window even
92
+
93
+ cutoff_frequency = sampling_interval / cutoff_period # Cutoff freq in sampling units
94
+
95
+ filter_kernel = _blackman_sinc_filter_kernel(kernel_window, cutoff_frequency)
96
+ smoothed_signals = _convolve_signals_with_kernel(signals, filter_kernel)
97
+
98
+ detrended_signals = signals - smoothed_signals
99
+ return detrended_signals
100
+
101
+ def smooth_gauss(signal:np.ndarray, sigma:float) -> np.ndarray:
102
+ """Applies a Gaussian smoothing filter to the 2D input signal.
103
+
104
+ :param signal: 2D numpy array of shape (T x n_rois)
105
+ :type signal: np.ndarray
106
+ :param sigma: Standard deviation for the Gaussian kernel.
107
+ :type sigma: float
108
+ :return: The smoothed signal of shape (T x n_rois).
109
+ :rtype: np.ndarray
110
+ """
111
+ if sigma <= 0:
112
+ return signal
113
+
114
+ return gaussian_filter1d(signal, sigma, axis=0)
@@ -0,0 +1,114 @@
1
+ import numpy as np
2
+ from typing import Tuple
3
+
4
+ def calculate_std_cov(peak_series: np.ndarray, frame_interval: float) -> Tuple[np.ndarray, np.ndarray]:
5
+ """
6
+ calculates the standard deviation and coefficient of variation (cov) of the peak intervals for each ROI in the peak series.
7
+
8
+ :param peak_series: T x n_roi binary array with 1s at the peak locations and 0s elsewhere
9
+ :type peak_series: np.ndarray
10
+ :param frame_interval: frame interval in seconds of the recording
11
+ :type frame_interval: float
12
+ :return: returns standard deviation (sd) and coefficient of variation (cov) of the peak intervals for each ROI
13
+ :rtype: Tuple[np.ndarray, np.ndarray]
14
+ """
15
+ # could probably be further sped up with proper vectorization
16
+
17
+ peak_indices = [np.nonzero(col)[0] for col in peak_series.T]
18
+ peak_diff = [np.ediff1d(indices) * frame_interval for indices in peak_indices]
19
+ peak_std = [np.std(row) for row in peak_diff]
20
+ peak_cov = [np.std(row) / np.mean(row) if len(row) > 1 and np.mean(row) != 0 else np.nan for row in peak_diff]
21
+
22
+ return np.asarray(peak_std), np.asarray(peak_cov)
23
+
24
+ def calculate_std_cov_thresholds(filtered_peak_series:np.ndarray, std_quantile:float, cov_quantile:float, frame_interval:float, n_iter:int=100) -> Tuple[float, float]:
25
+ """
26
+ finds the threshold of standard deviation and coefficient of variation for the random control data.
27
+
28
+ :param filtered_peak_series: (T x n_roi) binary array with 1s at the peak locations and 0s elsewhere, where n_roi are ROIs with at least 4 peaks
29
+ :type filtered_peak_series: np.ndarray
30
+ :param std_quantile: quantile for standard deviation threshold
31
+ :type std_quantile: float
32
+ :param cov_quantile: quantile for coefficient of variation threshold
33
+ :type cov_quantile: float
34
+ :param n_iter: number of iterations for random control
35
+ :type n_iter: 100
36
+ :param frame_interval: frame interval of the recording in seconds
37
+ :type frame_interval: float
38
+ :return: returns the standard deviation and coefficient of variation thresholds for the random control data
39
+ :rtype: Tuple[float, float]
40
+ """
41
+ # for the random control, generate a stacked peak series
42
+ stacked_peak_series = np.hstack([filtered_peak_series] * n_iter)
43
+
44
+ # shuffle each column independently
45
+ T, n = stacked_peak_series.shape
46
+ idx = np.argsort(np.random.rand(T, n), axis=0)
47
+ stacked_peak_series = np.take_along_axis(stacked_peak_series, idx, axis=0)
48
+
49
+ # calculate std and cv for each column
50
+ stds, cvs = calculate_std_cov(stacked_peak_series, frame_interval)
51
+
52
+ # calculate the quantiles
53
+ std_threshold = np.quantile(stds, std_quantile)
54
+ cov_threshold = np.quantile(cvs, cov_quantile)
55
+
56
+ return std_threshold, cov_threshold
57
+
58
+ def find_oscillating_rois(filtered_peak_series: np.ndarray, std_threshold: float, cov_threshold: float, frame_interval: float) -> Tuple[float, int, int, float, int, int]:
59
+ """
60
+ finds the oscillating ROIs based on the standard deviation and coefficient of variation thresholds.
61
+
62
+ :param filtered_peak_series: T x n_roi binary array with 1s at the peak locations and 0s elsewhere
63
+ :type filtered_peak_series: np.ndarray
64
+ :param std_threshold: threshold for standard deviation of peak intervals
65
+ :type std_threshold: float
66
+ :param cov_threshold: threshold for coefficient of variation of peak intervals
67
+ :type cov_threshold: float
68
+ :param frame_interval: frame interval in seconds of the recording
69
+ :type frame_interval: float
70
+ :return: returns the threshold, number of ROIs above the threshold for experimental data and random control data for std and cov respectively.
71
+ :rtype: Tuple[float, int, int, float, int, int]
72
+ """
73
+
74
+ # experimental data
75
+ exp_stds, exp_covs = calculate_std_cov(filtered_peak_series, frame_interval)
76
+ num_exp_stds = np.sum(exp_stds < std_threshold)
77
+ num_exp_covs = np.sum(exp_covs < cov_threshold)
78
+
79
+ # random control data
80
+ T, n = filtered_peak_series.shape
81
+ idx = np.argsort(np.random.rand(T, n), axis=0)
82
+ shuffled_peak_series = np.take_along_axis(filtered_peak_series, idx, axis=0)
83
+ rand_stds, rand_covs = calculate_std_cov(shuffled_peak_series, frame_interval)
84
+ num_rand_stds = np.sum(rand_stds < std_threshold)
85
+ num_rand_covs = np.sum(rand_covs < cov_threshold)
86
+
87
+ return std_threshold, num_exp_stds, num_rand_stds, cov_threshold, num_exp_covs, num_rand_covs
88
+
89
+ def find_oscillating_rois_from_peak_series(peak_series: np.ndarray, std_quantile: float, cov_quantile: float, frame_interval: float, n_iter: int = 100) -> Tuple[float, int, int, float, int, int]:
90
+ """
91
+ finds the oscillating ROIs based on the standard deviation and coefficient of variation thresholds.
92
+
93
+ :param peak_series: T x n_roi binary array with 1s at the peak locations and 0s elsewhere
94
+ :type peak_series: np.ndarray
95
+ :param std_quantile: quantile for standard deviation threshold
96
+ :type std_quantile: float
97
+ :param cov_quantile: quantile for coefficient of variation threshold
98
+ :type cov_quantile: float
99
+ :param n_iter: number of iterations for random control
100
+ :type n_iter: int
101
+ :param frame_interval: frame interval in seconds of the recording
102
+ :type frame_interval: float
103
+ :return: returns the threshold, number of ROIs above the threshold for experimental data and random control data for std and cov respectively.
104
+ :rtype: Tuple[float, int, int, float, int, int]
105
+ """
106
+
107
+ # filter the peak series to only include ROIs with at least 4 peaks
108
+ filtered_peak_series = peak_series[:, np.sum(peak_series, axis=0) >= 4]
109
+
110
+ # calculate the thresholds for standard deviation and coefficient of variation
111
+ std_threshold, cov_threshold = calculate_std_cov_thresholds(filtered_peak_series, std_quantile, cov_quantile, frame_interval, n_iter)
112
+
113
+ # find the oscillating ROIs based on the thresholds
114
+ return find_oscillating_rois(filtered_peak_series, std_threshold, cov_threshold, frame_interval)
@@ -0,0 +1,29 @@
1
+ from scipy.signal import find_peaks as scipy_find_peaks
2
+ import numpy as np
3
+
4
+ def find_peaks(peak_min_width:int, peak_max_width:int, peak_prominence:float, peak_min_height:float, detrended_timeseries:np.ndarray) -> np.ndarray:
5
+ """wrapper function for scipy's find_peaks function
6
+
7
+ :param peak_min_width: minimum width at half-maximum of peak in frames
8
+ :type peak_min_width: int
9
+ :param peak_max_width: maximum width at half-maxumim of peak in frames
10
+ :type peak_max_width: int
11
+ :param peak_prominence: minimum prominence of peak
12
+ :type peak_prominence: float
13
+ :param min_peak_height: minimum height of peak
14
+ :type min_peak_height: float
15
+ :param timeseries: the timeseries to find the peaks on
16
+ :type timeseries: ndarray
17
+
18
+ :return: returns a (T x n_roi) np.ndarray with 1s at the peak locations and 0s elsewhere
19
+ :rtype: np.ndarray
20
+ """
21
+
22
+ peak_series = np.zeros(detrended_timeseries.shape)
23
+
24
+ for i in range(0, detrended_timeseries.shape[1]):
25
+ peaks, _ = scipy_find_peaks(detrended_timeseries[:,i], width = (peak_min_width, peak_max_width), height=peak_min_height, prominence = peak_prominence)
26
+ peak_series[peaks,i] = 1
27
+
28
+ return peak_series
29
+