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.
- pykrait-0.1.0.dist-info/METADATA +56 -0
- pykrait-0.1.0.dist-info/RECORD +16 -0
- pykrait-0.1.0.dist-info/WHEEL +4 -0
- pykrait-0.1.0.dist-info/licenses/LICENSE +21 -0
- src/pykrait/__init__.py +1 -0
- src/pykrait/io/__init__.py +0 -0
- src/pykrait/io/io.py +117 -0
- src/pykrait/pipeline/__init__.py +1 -0
- src/pykrait/pipeline/pipeline.py +266 -0
- src/pykrait/preprocessing/__init__.py +1 -0
- src/pykrait/preprocessing/segmentation.py +176 -0
- src/pykrait/preprocessing/timeseries_extraction.py +329 -0
- src/pykrait/trace_analysis/__init__.py +1 -0
- src/pykrait/trace_analysis/filtering.py +114 -0
- src/pykrait/trace_analysis/oscillations.py +114 -0
- src/pykrait/trace_analysis/peak_analysis.py +29 -0
|
@@ -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
|
+

|
|
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,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.
|
src/pykrait/__init__.py
ADDED
|
@@ -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
|
+
|