pykrait 0.2.1__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pykrait-0.2.1 → pykrait-0.2.2}/PKG-INFO +3 -1
- {pykrait-0.2.1 → pykrait-0.2.2}/pyproject.toml +8 -3
- pykrait-0.2.2/src/pykrait/__main__.py +9 -0
- pykrait-0.2.2/src/pykrait/gui/app.py +69 -0
- {pykrait-0.2.1 → pykrait-0.2.2}/src/pykrait/gui/async_workers.py +98 -7
- {pykrait-0.2.1 → pykrait-0.2.2}/src/pykrait/gui/fileselection.py +2 -2
- {pykrait-0.2.1 → pykrait-0.2.2}/src/pykrait/gui/mainwindow.py +57 -68
- pykrait-0.2.2/src/pykrait/gui/resources/logo.png +0 -0
- {pykrait-0.2.1 → pykrait-0.2.2}/src/pykrait/io/io.py +98 -4
- pykrait-0.2.1/src/pykrait/__main__.py +0 -41
- {pykrait-0.2.1 → pykrait-0.2.2}/.gitignore +0 -0
- {pykrait-0.2.1 → pykrait-0.2.2}/LICENSE +0 -0
- {pykrait-0.2.1 → pykrait-0.2.2}/README.md +0 -0
- {pykrait-0.2.1 → pykrait-0.2.2}/src/.python-version +0 -0
- {pykrait-0.2.1 → pykrait-0.2.2}/src/pykrait/__init__.py +0 -0
- {pykrait-0.2.1 → pykrait-0.2.2}/src/pykrait/io/__init__.py +0 -0
- {pykrait-0.2.1 → pykrait-0.2.2}/src/pykrait/pipeline/__init__.py +0 -0
- {pykrait-0.2.1 → pykrait-0.2.2}/src/pykrait/pipeline/pipeline.py +0 -0
- {pykrait-0.2.1 → pykrait-0.2.2}/src/pykrait/preprocessing/__init__.py +0 -0
- {pykrait-0.2.1 → pykrait-0.2.2}/src/pykrait/preprocessing/segmentation.py +0 -0
- {pykrait-0.2.1 → pykrait-0.2.2}/src/pykrait/preprocessing/timeseries_extraction.py +0 -0
- {pykrait-0.2.1 → pykrait-0.2.2}/src/pykrait/trace_analysis/__init__.py +0 -0
- {pykrait-0.2.1 → pykrait-0.2.2}/src/pykrait/trace_analysis/filtering.py +0 -0
- {pykrait-0.2.1 → pykrait-0.2.2}/src/pykrait/trace_analysis/oscillations.py +0 -0
- {pykrait-0.2.1 → pykrait-0.2.2}/src/pykrait/trace_analysis/peak_analysis.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pykrait
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Python Calcium Recording Analysis and Inference Toolbox
|
|
5
5
|
Author-email: phaleanopsis <22663502+phaleanopsis@users.noreply.github.com>
|
|
6
6
|
License: MIT
|
|
@@ -8,6 +8,7 @@ License-File: LICENSE
|
|
|
8
8
|
Requires-Python: >=3.11
|
|
9
9
|
Requires-Dist: aicspylibczi>=3.3.1
|
|
10
10
|
Requires-Dist: bioio-czi>=2.4.0
|
|
11
|
+
Requires-Dist: bioio-imageio
|
|
11
12
|
Requires-Dist: bioio-ome-tiff>=1.1.0
|
|
12
13
|
Requires-Dist: bioio-tifffile>=1.3.0
|
|
13
14
|
Requires-Dist: bioio>=3.0.0
|
|
@@ -21,6 +22,7 @@ Requires-Dist: numcodecs<0.16
|
|
|
21
22
|
Requires-Dist: numpy<2
|
|
22
23
|
Requires-Dist: ome-types>=0.5.3
|
|
23
24
|
Requires-Dist: ome-zarr>=0.10.2
|
|
25
|
+
Requires-Dist: pandas>=2.3.1
|
|
24
26
|
Requires-Dist: pip>=25.1.1
|
|
25
27
|
Requires-Dist: pydantic<2
|
|
26
28
|
Requires-Dist: pyyaml>=6.0.2
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pykrait"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.2"
|
|
4
4
|
description = "Python Calcium Recording Analysis and Inference Toolbox"
|
|
5
5
|
readme = { file = "README.md", content-type = "text/markdown" }
|
|
6
6
|
license = { text = "MIT" }
|
|
@@ -14,6 +14,7 @@ dependencies = [
|
|
|
14
14
|
"bioio-czi>=2.4.0",
|
|
15
15
|
"bioio-ome-tiff>=1.1.0",
|
|
16
16
|
"bioio-tifffile>=1.3.0",
|
|
17
|
+
"bioio-imageio",
|
|
17
18
|
"cellpose>=4.0.5",
|
|
18
19
|
"dask>=2025.5.1",
|
|
19
20
|
"fsspec>=2022.8.0",
|
|
@@ -31,11 +32,12 @@ dependencies = [
|
|
|
31
32
|
"scikit-image>=0.25.2",
|
|
32
33
|
"scipy>=1.15.3",
|
|
33
34
|
"xarray>2025",
|
|
35
|
+
"pandas>=2.3.1",
|
|
34
36
|
]
|
|
35
37
|
|
|
36
38
|
[project.optional-dependencies]
|
|
37
39
|
gui = [
|
|
38
|
-
"pyside6>=6.9.1"
|
|
40
|
+
"pyside6>=6.9.1",
|
|
39
41
|
]
|
|
40
42
|
dev = [
|
|
41
43
|
"build>=1.2.2.post1",
|
|
@@ -70,8 +72,11 @@ pythonpath = ["src"]
|
|
|
70
72
|
testpaths = ["tests"]
|
|
71
73
|
|
|
72
74
|
[tool.coverage.run]
|
|
73
|
-
source = ["
|
|
75
|
+
source = ["pykrait"]
|
|
74
76
|
|
|
75
77
|
[tool.coverage.report]
|
|
76
78
|
show_missing = true
|
|
77
79
|
skip_covered = true
|
|
80
|
+
|
|
81
|
+
[tool.setuptools.package-data]
|
|
82
|
+
"pykrait.gui.resources" = ["logo.png"]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
from PySide6.QtCore import QSize
|
|
4
|
+
from PySide6.QtGui import QIcon
|
|
5
|
+
from PySide6.QtWidgets import QApplication
|
|
6
|
+
from pykrait.gui.fileselection import FileSelectionWindow
|
|
7
|
+
from pykrait.gui.mainwindow import MainWindow
|
|
8
|
+
|
|
9
|
+
class AppController:
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self.app = QApplication(sys.argv)
|
|
12
|
+
|
|
13
|
+
# Loading icon from resources
|
|
14
|
+
appIcon = QIcon()
|
|
15
|
+
try:
|
|
16
|
+
from importlib.resources import files
|
|
17
|
+
icon_path = files("pykrait.gui.resources").joinpath("logo.png")
|
|
18
|
+
os.path.exists(icon_path)
|
|
19
|
+
except Exception as e:
|
|
20
|
+
icon_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "pykrait/gui/resources", "logo.png"))
|
|
21
|
+
os.path.exists(icon_path)
|
|
22
|
+
if os.path.exists(icon_path):
|
|
23
|
+
appIcon.addFile(str(icon_path), QSize(16, 16))
|
|
24
|
+
appIcon.addFile(str(icon_path), QSize(24, 24))
|
|
25
|
+
appIcon.addFile(str(icon_path), QSize(32, 32))
|
|
26
|
+
appIcon.addFile(str(icon_path), QSize(48, 48))
|
|
27
|
+
appIcon.addFile(str(icon_path), QSize(64, 64))
|
|
28
|
+
appIcon.addFile(str(icon_path), QSize(256, 256))
|
|
29
|
+
self.app.setWindowIcon(appIcon)
|
|
30
|
+
else:
|
|
31
|
+
print(f"Warning: Icon file not found at {icon_path}")
|
|
32
|
+
|
|
33
|
+
# Set application name and version
|
|
34
|
+
self.app.setApplicationName("pyKrait")
|
|
35
|
+
self.app.setApplicationVersion("0.2.1")
|
|
36
|
+
self.file_selection = FileSelectionWindow()
|
|
37
|
+
self.file_selection.analysis_complete.connect(self.on_analysis_complete)
|
|
38
|
+
self.file_selection.show()
|
|
39
|
+
|
|
40
|
+
# Keep references to windows
|
|
41
|
+
self.main_window = None
|
|
42
|
+
|
|
43
|
+
def on_analysis_complete(self, results):
|
|
44
|
+
# Called when analysis is done in FileSelectionWindow
|
|
45
|
+
# Hide file selection window
|
|
46
|
+
self.file_selection.close()
|
|
47
|
+
# Launch main window, keep reference
|
|
48
|
+
self.main_window = MainWindow(
|
|
49
|
+
frames=results["frames"],
|
|
50
|
+
mask=results["masks"],
|
|
51
|
+
rois=results["rois"],
|
|
52
|
+
mean_intensities=results["mean_intensities"],
|
|
53
|
+
analysis_output=results["analysis_output"],
|
|
54
|
+
analysis_params=results["analysis_parameters"],
|
|
55
|
+
app_controller=self
|
|
56
|
+
)
|
|
57
|
+
self.main_window.show()
|
|
58
|
+
|
|
59
|
+
def run(self):
|
|
60
|
+
sys.exit(self.app.exec())
|
|
61
|
+
|
|
62
|
+
def restart(self):
|
|
63
|
+
if self.main_window:
|
|
64
|
+
self.main_window.close()
|
|
65
|
+
self.main_window = None
|
|
66
|
+
|
|
67
|
+
self.file_selection = FileSelectionWindow()
|
|
68
|
+
self.file_selection.analysis_complete.connect(self.on_analysis_complete)
|
|
69
|
+
self.file_selection.show()
|
|
@@ -5,7 +5,9 @@ import os
|
|
|
5
5
|
import traceback
|
|
6
6
|
import numpy as np
|
|
7
7
|
|
|
8
|
-
from
|
|
8
|
+
from skimage.measure import find_contours
|
|
9
|
+
|
|
10
|
+
from pykrait.io.io import load_timelapse_lazy, read_label_image, get_pykrait_version, save_Txnrois
|
|
9
11
|
from pykrait.preprocessing.segmentation import timelapse_projection, create_cellpose_segmentation
|
|
10
12
|
from pykrait.preprocessing.timeseries_extraction import extract_mean_intensities
|
|
11
13
|
from pykrait.trace_analysis.filtering import detrend_with_sinc_filter
|
|
@@ -13,6 +15,34 @@ from pykrait.trace_analysis.peak_analysis import find_peaks
|
|
|
13
15
|
from pykrait.trace_analysis.oscillations import find_oscillating_rois
|
|
14
16
|
from pykrait.pipeline.pipeline import AnalysisParameters, AnalysisOutput
|
|
15
17
|
|
|
18
|
+
def get_pixel_contour_from_label_img(label_img:np.ndarray, orig_shape:tuple, target_shape:tuple) -> list:
|
|
19
|
+
"""
|
|
20
|
+
Generates pixel contours from a labeled image, scaled to a target shape.
|
|
21
|
+
|
|
22
|
+
:param label_img: a label image where each pixel is labeled with an integer representing the ROI it belongs to
|
|
23
|
+
:type label_img: np.ndarray
|
|
24
|
+
:param orig_shape: original shape of the image (height, width)
|
|
25
|
+
:type orig_shape: tuple
|
|
26
|
+
:param target_shape: target shape to which the contours should be scaled (height, width)
|
|
27
|
+
:type target_shape: tuple
|
|
28
|
+
:return: returns a list of polygons representing the scaled contours of each ROI
|
|
29
|
+
:rtype: list
|
|
30
|
+
"""
|
|
31
|
+
roi_polygons = []
|
|
32
|
+
orig_height, orig_width = orig_shape
|
|
33
|
+
target_height, target_width = target_shape
|
|
34
|
+
scale_y = target_height / orig_height
|
|
35
|
+
scale_x = target_width / orig_width
|
|
36
|
+
for index in range(1, label_img.max() + 1): # skip background (0)
|
|
37
|
+
contours = find_contours(label_img == index, 0.5)
|
|
38
|
+
if contours:
|
|
39
|
+
contour = contours[0]
|
|
40
|
+
y, x = contour.T
|
|
41
|
+
y, x = y * scale_y, x * scale_x # rescales coordinates to target shape
|
|
42
|
+
polygon = list(zip(x, y))
|
|
43
|
+
roi_polygons.append(polygon)
|
|
44
|
+
return roi_polygons
|
|
45
|
+
|
|
16
46
|
class ExtractIntensitiesWorker(QObject):
|
|
17
47
|
progress_changed = Signal(int, str) # progress percent, status message
|
|
18
48
|
finished = Signal(dict) # analysis results
|
|
@@ -30,6 +60,7 @@ class ExtractIntensitiesWorker(QObject):
|
|
|
30
60
|
|
|
31
61
|
self.analysis_output.filename = self.analysis_output.filepath.split("/")[-1]
|
|
32
62
|
print(f"Running analysis on {self.analysis_output.filename}")
|
|
63
|
+
|
|
33
64
|
# Create analysis folder in the parent directory of the video file
|
|
34
65
|
parent_dir = os.path.dirname(self.analysis_output.filepath)
|
|
35
66
|
filename_wo_ext = os.path.splitext(self.analysis_output.filename)[0]
|
|
@@ -41,13 +72,21 @@ class ExtractIntensitiesWorker(QObject):
|
|
|
41
72
|
|
|
42
73
|
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)
|
|
43
74
|
|
|
44
|
-
if frame_interval is None and self.analysis_parameters.frame_interval is None:
|
|
45
|
-
warnings.warn("No frame interval
|
|
46
|
-
self.analysis_output.frame_interval = 1
|
|
47
|
-
elif
|
|
75
|
+
if frame_interval is None and self.analysis_parameters.frame_interval is None: #nothing provided in metadata or parameters
|
|
76
|
+
warnings.warn("No frame interval found in metadata and no frame interval provided in parameters. Using default value of 1.0 seconds.", UserWarning)
|
|
77
|
+
self.analysis_output.frame_interval = 1.0
|
|
78
|
+
elif frame_interval is None and self.analysis_parameters.frame_interval is not None: # no metadata, but parameter provided
|
|
79
|
+
self.analysis_output.frame_interval = self.analysis_parameters.frame_interval
|
|
80
|
+
elif frame_interval is not None and self.analysis_parameters.frame_interval is None: # metadata provided, but no parameter
|
|
48
81
|
self.analysis_output.frame_interval = frame_interval
|
|
49
|
-
elif frame_interval
|
|
82
|
+
elif round(frame_interval, 2) == round(self.analysis_parameters.frame_interval, 2): # both provided and match
|
|
83
|
+
self.analysis_output.frame_interval = frame_interval
|
|
84
|
+
elif round(frame_interval, 2) != round(self.analysis_parameters.frame_interval, 2):
|
|
50
85
|
warnings.warn(f"Provided frame_interval ({self.analysis_parameters.frame_interval}) does not match inferred frame_interval ({frame_interval}) from metadata. Using provided value.")
|
|
86
|
+
self.analysis_output.frame_interval = self.analysis_parameters.frame_interval
|
|
87
|
+
else:
|
|
88
|
+
warnings.warn("Unexpected paramets in metadata frame interval {frame_interval} and provided frame interval {self.analyis_parameters.frame_interval}. Using default value of 1.0 seconds.", UserWarning)
|
|
89
|
+
self.analysis_output.frame_interval = 1.0
|
|
51
90
|
|
|
52
91
|
if self.mode == "cellpose":
|
|
53
92
|
# perform the tproj
|
|
@@ -67,7 +106,7 @@ class ExtractIntensitiesWorker(QObject):
|
|
|
67
106
|
|
|
68
107
|
elif self.mode == "label_image":
|
|
69
108
|
self.progress_changed.emit(20, "Loading label image...")
|
|
70
|
-
masks =
|
|
109
|
+
masks = read_label_image(self.analysis_output.masks_path)
|
|
71
110
|
tproj = None # Not needed in this mode
|
|
72
111
|
|
|
73
112
|
else:
|
|
@@ -79,6 +118,9 @@ class ExtractIntensitiesWorker(QObject):
|
|
|
79
118
|
self.analysis_output.number_of_frames, self.analysis_output.number_of_cells = mean_intensities.shape
|
|
80
119
|
|
|
81
120
|
# All done
|
|
121
|
+
self.progress_changed.emit(90, "Calculating ROI boundaries...")
|
|
122
|
+
rois = get_pixel_contour_from_label_img(masks, orig_shape=masks.shape, target_shape=(512, 512))
|
|
123
|
+
|
|
82
124
|
self.progress_changed.emit(100, "Done.")
|
|
83
125
|
|
|
84
126
|
results = {
|
|
@@ -86,6 +128,7 @@ class ExtractIntensitiesWorker(QObject):
|
|
|
86
128
|
'analysis_output': self.analysis_output,
|
|
87
129
|
'analysis_parameters': self.analysis_parameters,
|
|
88
130
|
'masks': masks,
|
|
131
|
+
'rois': rois,
|
|
89
132
|
'mean_intensities': mean_intensities
|
|
90
133
|
}
|
|
91
134
|
self.finished.emit(results)
|
|
@@ -165,3 +208,51 @@ class PeriodicCellWorker(QObject):
|
|
|
165
208
|
except Exception as e:
|
|
166
209
|
self.error.emit(e)
|
|
167
210
|
|
|
211
|
+
class AnalysisSaveWorker(QObject):
|
|
212
|
+
finished = Signal(str) # emit output file path or summary message
|
|
213
|
+
error = Signal(Exception)
|
|
214
|
+
|
|
215
|
+
def __init__(self, analysis_output, analysis_params, peaks: np.ndarray, detrended_intensities: np.ndarray, intensities: np.ndarray,
|
|
216
|
+
overwrite: bool,
|
|
217
|
+
):
|
|
218
|
+
super().__init__()
|
|
219
|
+
self.analysis_output = analysis_output
|
|
220
|
+
self.analysis_params = analysis_params
|
|
221
|
+
self.peaks = peaks
|
|
222
|
+
self.detrended_intensities = detrended_intensities
|
|
223
|
+
self.intensities = intensities
|
|
224
|
+
self.overwrite = overwrite
|
|
225
|
+
|
|
226
|
+
def run(self):
|
|
227
|
+
try:
|
|
228
|
+
# Paths
|
|
229
|
+
filename = os.path.basename(self.analysis_output.filepath)
|
|
230
|
+
filename_wo_ext = os.path.splitext(filename)[0]
|
|
231
|
+
parent_dir = os.path.dirname(self.analysis_output.filepath)
|
|
232
|
+
analysis_folder = os.path.join(parent_dir, f"Analysis_{filename_wo_ext}")
|
|
233
|
+
os.makedirs(analysis_folder, exist_ok=True)
|
|
234
|
+
|
|
235
|
+
output_file = os.path.join(analysis_folder, f"{filename_wo_ext}_analysis_output.csv")
|
|
236
|
+
param_file = os.path.join(analysis_folder, f"{filename_wo_ext}_analysis_parameters.csv")
|
|
237
|
+
|
|
238
|
+
if not self.overwrite:
|
|
239
|
+
for f in [output_file, param_file]:
|
|
240
|
+
if os.path.exists(f):
|
|
241
|
+
raise FileExistsError(f"{f} already exists. Enable 'overwrite' to replace.")
|
|
242
|
+
|
|
243
|
+
# Save output
|
|
244
|
+
self.analysis_output.pykrait_version = get_pykrait_version()
|
|
245
|
+
self.analysis_output.timestamp = np.datetime64("now")
|
|
246
|
+
|
|
247
|
+
self.analysis_output.to_pandas().to_csv(output_file, index=False)
|
|
248
|
+
self.analysis_params.to_pandas().to_csv(param_file, index=False)
|
|
249
|
+
|
|
250
|
+
# Save arrays
|
|
251
|
+
save_Txnrois(self.peaks, self.analysis_output.frame_interval, os.path.join(analysis_folder, f"{filename_wo_ext}_peaks.csv"))
|
|
252
|
+
save_Txnrois(self.detrended_intensities, self.analysis_output.frame_interval, os.path.join(analysis_folder, f"{filename_wo_ext}_detrended_intensities.csv"))
|
|
253
|
+
save_Txnrois(self.intensities, self.analysis_output.frame_interval, os.path.join(analysis_folder, f"{filename_wo_ext}_raw_intensities.csv"))
|
|
254
|
+
|
|
255
|
+
self.finished.emit(f"Saved to {analysis_folder}")
|
|
256
|
+
|
|
257
|
+
except Exception as e:
|
|
258
|
+
self.error.emit(e)
|
|
@@ -206,7 +206,7 @@ class FileSelectionWindow(QWidget):
|
|
|
206
206
|
last_dir = self.get_last_directory("last_model_dir")
|
|
207
207
|
path, _ = QFileDialog.getOpenFileName(
|
|
208
208
|
self, "Select Cellpose Model", last_dir,
|
|
209
|
-
filter="
|
|
209
|
+
filter="All Files (*)"
|
|
210
210
|
)
|
|
211
211
|
if path:
|
|
212
212
|
self.model_path = path
|
|
@@ -219,7 +219,7 @@ class FileSelectionWindow(QWidget):
|
|
|
219
219
|
last_dir = self.get_last_directory("last_image_dir")
|
|
220
220
|
path, _ = QFileDialog.getOpenFileName(
|
|
221
221
|
self, "Select Label Image", last_dir,
|
|
222
|
-
filter="Label Files (*.tif *.tiff *.npy);;All Files (*)"
|
|
222
|
+
filter="Label Files (*.tif *.tiff *.npy, *.png);;All Files (*)"
|
|
223
223
|
)
|
|
224
224
|
if path:
|
|
225
225
|
self.label_image_path = path
|
|
@@ -1,55 +1,25 @@
|
|
|
1
|
-
import os
|
|
2
1
|
import numpy as np
|
|
3
2
|
import dask.array as da
|
|
4
3
|
from matplotlib import cm
|
|
5
4
|
|
|
5
|
+
|
|
6
6
|
from PySide6.QtWidgets import (
|
|
7
7
|
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QMessageBox, QLabel,
|
|
8
8
|
QFormLayout, QPushButton, QSplitter, QSlider, QDoubleSpinBox, QGroupBox,
|
|
9
9
|
QCheckBox
|
|
10
10
|
)
|
|
11
11
|
from PySide6.QtCore import Qt, QPointF, Signal, QThread
|
|
12
|
-
from PySide6.QtGui import QImage, QPixmap, QPen, QPolygonF
|
|
12
|
+
from PySide6.QtGui import QImage, QPixmap, QPen, QPolygonF, QAction
|
|
13
13
|
from PySide6.QtWidgets import QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QGraphicsPolygonItem
|
|
14
14
|
|
|
15
15
|
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
|
|
16
16
|
from matplotlib.figure import Figure
|
|
17
17
|
|
|
18
|
-
from skimage.measure import find_contours
|
|
19
|
-
|
|
20
18
|
from pykrait.io.io import get_pykrait_version
|
|
21
19
|
from pykrait.pipeline.pipeline import AnalysisParameters, AnalysisOutput
|
|
22
|
-
from pykrait.gui.async_workers import DetrendWorker, PeakDetectionWorker, PeriodicCellWorker
|
|
20
|
+
from pykrait.gui.async_workers import DetrendWorker, PeakDetectionWorker, PeriodicCellWorker, AnalysisSaveWorker
|
|
23
21
|
from pykrait.trace_analysis.peak_analysis import calculate_normalized_peaks
|
|
24
22
|
|
|
25
|
-
def get_pixel_contour_from_label_img(label_img:np.ndarray, orig_shape:tuple, target_shape:tuple) -> list:
|
|
26
|
-
"""
|
|
27
|
-
Generates pixel contours from a labeled image, scaled to a target shape.
|
|
28
|
-
|
|
29
|
-
:param label_img: a label image where each pixel is labeled with an integer representing the ROI it belongs to
|
|
30
|
-
:type label_img: np.ndarray
|
|
31
|
-
:param orig_shape: original shape of the image (height, width)
|
|
32
|
-
:type orig_shape: tuple
|
|
33
|
-
:param target_shape: target shape to which the contours should be scaled (height, width)
|
|
34
|
-
:type target_shape: tuple
|
|
35
|
-
:return: returns a list of polygons representing the scaled contours of each ROI
|
|
36
|
-
:rtype: list
|
|
37
|
-
"""
|
|
38
|
-
roi_polygons = []
|
|
39
|
-
orig_height, orig_width = orig_shape
|
|
40
|
-
target_height, target_width = target_shape
|
|
41
|
-
scale_y = target_height / orig_height
|
|
42
|
-
scale_x = target_width / orig_width
|
|
43
|
-
for index in range(1, label_img.max() + 1): # skip background (0)
|
|
44
|
-
contours = find_contours(label_img == index, 0.5)
|
|
45
|
-
if contours:
|
|
46
|
-
contour = contours[0]
|
|
47
|
-
y, x = contour.T
|
|
48
|
-
y, x = y * scale_y, x * scale_x # rescales coordinates to target shape
|
|
49
|
-
polygon = list(zip(x, y))
|
|
50
|
-
roi_polygons.append(polygon)
|
|
51
|
-
return roi_polygons
|
|
52
|
-
|
|
53
23
|
class ImageDisplay(QGraphicsView):
|
|
54
24
|
roiSelected = Signal(int)
|
|
55
25
|
|
|
@@ -165,15 +135,16 @@ class ImageDisplay(QGraphicsView):
|
|
|
165
135
|
|
|
166
136
|
|
|
167
137
|
class MainWindow(QMainWindow):
|
|
168
|
-
def __init__(self, frames:da.Array, mask:np.ndarray, mean_intensities:np.ndarray, analysis_output:AnalysisOutput, analysis_params:AnalysisParameters):
|
|
138
|
+
def __init__(self, frames:da.Array, mask:np.ndarray, mean_intensities:np.ndarray, rois:list, analysis_output:AnalysisOutput, analysis_params:AnalysisParameters, app_controller=None):
|
|
169
139
|
super().__init__()
|
|
140
|
+
self.app_controller = app_controller
|
|
170
141
|
self.frames = frames
|
|
171
142
|
self.mask = mask
|
|
172
|
-
self.rois =
|
|
143
|
+
self.rois = rois
|
|
173
144
|
self.intensities = mean_intensities
|
|
174
|
-
self.setWindowTitle("Calcium Imaging Analysis")
|
|
175
145
|
self.analysis_output = analysis_output
|
|
176
146
|
self.analysis_params = analysis_params
|
|
147
|
+
self.setWindowTitle(f"{self.analysis_output.filename}; pyKrait {get_pykrait_version()}")
|
|
177
148
|
|
|
178
149
|
self.selected_roi = 0 # Default to first ROI
|
|
179
150
|
self.current_frame = 0
|
|
@@ -181,6 +152,8 @@ class MainWindow(QMainWindow):
|
|
|
181
152
|
self.detrended_intensities = None
|
|
182
153
|
self.peaks = None
|
|
183
154
|
|
|
155
|
+
# menu bar
|
|
156
|
+
self._create_menu_bar()
|
|
184
157
|
# Central widget and main layout
|
|
185
158
|
central_widget = QWidget()
|
|
186
159
|
main_layout = QHBoxLayout(central_widget)
|
|
@@ -346,6 +319,19 @@ class MainWindow(QMainWindow):
|
|
|
346
319
|
self.find_peaks_btn.clicked.connect(self.find_peaks)
|
|
347
320
|
self.periodicity_button.clicked.connect(self._start_periodicity_worker)
|
|
348
321
|
|
|
322
|
+
def _create_menu_bar(self):
|
|
323
|
+
self.menu_bar = self.menuBar()
|
|
324
|
+
|
|
325
|
+
file_menu = self.menu_bar.addMenu("File")
|
|
326
|
+
|
|
327
|
+
restart_action = QAction("New Analysis...", self)
|
|
328
|
+
restart_action.triggered.connect(self._on_new_analysis)
|
|
329
|
+
file_menu.addAction(restart_action)
|
|
330
|
+
|
|
331
|
+
exit_action = QAction("Exit", self)
|
|
332
|
+
exit_action.triggered.connect(self.close)
|
|
333
|
+
file_menu.addAction(exit_action)
|
|
334
|
+
|
|
349
335
|
def on_slider_moved(self, idx):
|
|
350
336
|
self.current_frame = idx
|
|
351
337
|
self.image_display.display_dask_frame(self.frames[self.current_frame, 0, :, :])
|
|
@@ -442,8 +428,6 @@ class MainWindow(QMainWindow):
|
|
|
442
428
|
self.peak_thread.finished.connect(self.peak_thread.deleteLater)
|
|
443
429
|
self.peak_thread.start()
|
|
444
430
|
|
|
445
|
-
|
|
446
|
-
|
|
447
431
|
def _start_detrend_worker(self, sinc_value):
|
|
448
432
|
self.detrend_thread = QThread()
|
|
449
433
|
self.detrend_worker = DetrendWorker(self.intensities, sinc_value, self.analysis_output.frame_interval)
|
|
@@ -473,7 +457,13 @@ class MainWindow(QMainWindow):
|
|
|
473
457
|
self.find_peaks_btn.setEnabled(True)
|
|
474
458
|
self.analysis_output.normalized_peaks = calculate_normalized_peaks(peaks, self.analysis_output.frame_interval)
|
|
475
459
|
self.analysis_output.cells_four_peaks = np.sum(np.sum(self.peaks, axis=0) >= 4)
|
|
476
|
-
percentage_four_peaks = (self.analysis_output.cells_four_peaks / self.peaks.shape[
|
|
460
|
+
percentage_four_peaks = (self.analysis_output.cells_four_peaks / self.peaks.shape[1]) * 100
|
|
461
|
+
|
|
462
|
+
# update the analysis parameters
|
|
463
|
+
self.analysis_params.peak_min_width = self.peak_min_width_spin.value()
|
|
464
|
+
self.analysis_params.peak_max_width = self.peak_max_width_spin.value()
|
|
465
|
+
self.analysis_params.peak_prominence = self.peak_prominence_spin.value()
|
|
466
|
+
self.analysis_params.peak_min_height = self.peak_min_height_spin.value()
|
|
477
467
|
|
|
478
468
|
# display the peak stats
|
|
479
469
|
self.norm_peak_label.setText(f"{self.analysis_output.normalized_peaks:.2f}")
|
|
@@ -533,34 +523,33 @@ class MainWindow(QMainWindow):
|
|
|
533
523
|
QMessageBox.critical(self, "Error", f"Periodic ROI analysis failed: {error}")
|
|
534
524
|
|
|
535
525
|
def _save_analysis_results(self):
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
526
|
+
self.analysis_save_thread = QThread()
|
|
527
|
+
self.analysis_worker = AnalysisSaveWorker(
|
|
528
|
+
analysis_output=self.analysis_output,
|
|
529
|
+
analysis_params=self.analysis_params,
|
|
530
|
+
peaks=self.peaks,
|
|
531
|
+
detrended_intensities=self.detrended_intensities,
|
|
532
|
+
intensities=self.intensities,
|
|
533
|
+
overwrite=self.overwrite_checkbox.isChecked(),
|
|
534
|
+
)
|
|
535
|
+
self.analysis_worker.moveToThread(self.analysis_save_thread)
|
|
536
|
+
|
|
537
|
+
self.analysis_save_thread.started.connect(self.analysis_worker.run)
|
|
538
|
+
self.analysis_worker.finished.connect(self.on_save_finished)
|
|
539
|
+
self.analysis_worker.error.connect(self.on_save_error)
|
|
540
|
+
self.analysis_worker.finished.connect(self.analysis_save_thread.quit)
|
|
541
|
+
self.analysis_worker.finished.connect(self.analysis_worker.deleteLater)
|
|
542
|
+
self.analysis_save_thread.finished.connect(self.analysis_save_thread.deleteLater)
|
|
543
|
+
|
|
544
|
+
self.analysis_save_thread.start()
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def on_save_finished(self, _: str):
|
|
548
|
+
QMessageBox.information(self, "Save Complete", "Analysis results saved successfully.")
|
|
539
549
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
self.analysis_output.filename = os.path.basename(self.analysis_output.filepath)
|
|
543
|
-
filename_wo_ext = os.path.splitext(self.analysis_output.filename)[0]
|
|
544
|
-
parent_dir = os.path.dirname(self.analysis_output.filepath)
|
|
545
|
-
analysis_folder = os.path.join(parent_dir, f"Analysis_{filename_wo_ext}")
|
|
546
|
-
|
|
547
|
-
output_file = os.path.join(analysis_folder, f"{filename_wo_ext}_analysis_output.csv")
|
|
548
|
-
param_file = os.path.join(analysis_folder, f"{filename_wo_ext}_analysis_parameters.csv")
|
|
549
|
-
|
|
550
|
-
# Handle overwrite option
|
|
551
|
-
if not self.overwrite_checkbox.isChecked():
|
|
552
|
-
for f in [output_file, param_file]:
|
|
553
|
-
if os.path.exists(f):
|
|
554
|
-
QMessageBox.warning(self, "File Exists", "Analysis Output already exists. Uncheck 'Overwrite' to prevent overwrite.")
|
|
555
|
-
return
|
|
556
|
-
|
|
557
|
-
# Save to CSV
|
|
558
|
-
self.analysis_output.pykrait_version = get_pykrait_version()
|
|
559
|
-
self.analysis_output.timestamp = np.datetime64('now')
|
|
560
|
-
self.analysis_output.to_pandas().to_csv(output_file, index=False)
|
|
561
|
-
self.analysis_params.to_pandas().to_csv(param_file, index=False)
|
|
562
|
-
print(f"Analysis results saved to {output_file} and {param_file}")
|
|
563
|
-
|
|
564
|
-
except Exception as e:
|
|
565
|
-
QMessageBox.critical(self, "Error", f"Failed to save: {str(e)}")
|
|
550
|
+
def on_save_error(self, e: Exception):
|
|
551
|
+
QMessageBox.critical(self, "Save Error", str(e))
|
|
566
552
|
|
|
553
|
+
def _on_new_analysis(self):
|
|
554
|
+
if self.app_controller:
|
|
555
|
+
self.app_controller.restart()
|
|
Binary file
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import dask.array as da
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
3
5
|
import warnings
|
|
4
6
|
import re
|
|
5
|
-
from importlib.metadata import version
|
|
7
|
+
from importlib.metadata import version
|
|
6
8
|
|
|
7
9
|
from bioio import BioImage
|
|
8
10
|
import bioio_tifffile
|
|
@@ -76,10 +78,15 @@ def load_timelapse_lazy(
|
|
|
76
78
|
|
|
77
79
|
T, C, Z, Y, X = dask_img.shape
|
|
78
80
|
|
|
79
|
-
if Z == 1:
|
|
81
|
+
if T > 1 and Z == 1:
|
|
82
|
+
dask_img = dask_img[:, :, 0, :, :] # shape: (T, C, Z, Y, X)
|
|
83
|
+
elif len([dim for dim in dask_img.shape if dim != 1]) == 3: #
|
|
84
|
+
print("Image has three non-singleton dimensions, presuming order is (T, Y, X).")
|
|
85
|
+
dask_img = _reorder_dask_array(dask_img) # reorder to (T, C, Y, X)
|
|
80
86
|
dask_img = dask_img[:, :, 0, :, :] # shape: (T, C, Z, Y, X)
|
|
81
87
|
else:
|
|
82
|
-
raise ValueError(f"Cannot drop Z dimension automatically: Z={Z}. Consider handling it explicitly.")
|
|
88
|
+
raise ValueError(f"Cannot drop Z dimension automatically: Z={Z}. Consider handling it explicitly. Image shape: {dask_img.shape}")
|
|
89
|
+
|
|
83
90
|
|
|
84
91
|
print(f"Returning lazy array of shape {dask_img.shape} (T, C, Y, X)")
|
|
85
92
|
return dask_img, frame_interval, y_um, x_um
|
|
@@ -110,4 +117,91 @@ def get_pykrait_version() -> str:
|
|
|
110
117
|
return __version__
|
|
111
118
|
except Exception as e:
|
|
112
119
|
print(f"Could not retrieve pykrait version: {e}")
|
|
113
|
-
return None # Default version if not found
|
|
120
|
+
return None # Default version if not found
|
|
121
|
+
|
|
122
|
+
def _reorder_dask_array(
|
|
123
|
+
dask_img: da.Array,
|
|
124
|
+
) -> da.Array:
|
|
125
|
+
"""Reorders a Dask array to the specified order.
|
|
126
|
+
|
|
127
|
+
:param dask_array: input Dask array
|
|
128
|
+
:type dask_array: da.Array
|
|
129
|
+
:return: reordered Dask array
|
|
130
|
+
:rtype: da.Array
|
|
131
|
+
"""
|
|
132
|
+
shape = dask_img.shape
|
|
133
|
+
non_1_axes = [i for i, dim in enumerate(shape) if dim != 1]
|
|
134
|
+
|
|
135
|
+
if len(non_1_axes) < 3:
|
|
136
|
+
raise ValueError(f"Expected at least 3 non-1 dimensions in {shape}")
|
|
137
|
+
|
|
138
|
+
# Identify T, Y, X from non-1 dimensions
|
|
139
|
+
non1_indices = [i for i, d in enumerate(dask_img.shape) if d != 1]
|
|
140
|
+
|
|
141
|
+
T_axis = non1_indices[0] # First non-singleton = T
|
|
142
|
+
Y_axis = non1_indices[-2] # Second last non-singleton = Y
|
|
143
|
+
X_axis = non1_indices[-1] # Last non-singleton = X
|
|
144
|
+
|
|
145
|
+
# Fill in dummy axes for C and Z
|
|
146
|
+
all_axes = list(range(5))
|
|
147
|
+
used_axes = {T_axis, Y_axis, X_axis}
|
|
148
|
+
unused_axes = [a for a in all_axes if a not in used_axes]
|
|
149
|
+
|
|
150
|
+
# Fix order to (T, C, Z, Y, X)
|
|
151
|
+
axis_map = {
|
|
152
|
+
'T': T_axis,
|
|
153
|
+
'C': unused_axes[0],
|
|
154
|
+
'Z': unused_axes[1],
|
|
155
|
+
'Y': Y_axis,
|
|
156
|
+
'X': X_axis
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
perm = [axis_map[k] for k in ['T', 'C', 'Z', 'Y', 'X']]
|
|
160
|
+
|
|
161
|
+
return dask_img.transpose(*perm)
|
|
162
|
+
|
|
163
|
+
def read_label_image(
|
|
164
|
+
file_path: str,
|
|
165
|
+
) -> np.ndarray:
|
|
166
|
+
"""Reads a label image from a file and returns it as a Dask array.
|
|
167
|
+
|
|
168
|
+
:param file_path: path to the label image file
|
|
169
|
+
:type file_path: str
|
|
170
|
+
:return: Dask array of the label image
|
|
171
|
+
:rtype: da.Array
|
|
172
|
+
"""
|
|
173
|
+
if not os.path.exists(file_path):
|
|
174
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
img = BioImage(file_path, reconstruct_mosaic=True).dask_data.compute()
|
|
178
|
+
return img[0,0,0,:,:] # Assuming the label image is single-channel, return as (Y, X)
|
|
179
|
+
except Exception as e:
|
|
180
|
+
raise ValueError(f"Failed to read label image from {file_path}: {type(e).__name__}: {e}")
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
def save_Txnrois(array:np.ndarray, frame_interval: float, filepath:str) -> None:
|
|
184
|
+
"""
|
|
185
|
+
Saves a T x n_roi array to a CSV file with a header to denote the ROIs and a time index
|
|
186
|
+
|
|
187
|
+
:param array: T x n_roi array to save
|
|
188
|
+
:type array: np.ndarray
|
|
189
|
+
:param filename: name of the output file
|
|
190
|
+
:type filename: str
|
|
191
|
+
:param frame_interval: frame interval in seconds
|
|
192
|
+
:type frame_interval: float
|
|
193
|
+
"""
|
|
194
|
+
if not isinstance(array, np.ndarray):
|
|
195
|
+
raise TypeError("Input must be a numpy ndarray")
|
|
196
|
+
|
|
197
|
+
if array.ndim != 2:
|
|
198
|
+
raise ValueError("Input array must be 2D (T x n_roi)")
|
|
199
|
+
|
|
200
|
+
n_frames, n_rois = array.shape
|
|
201
|
+
timepoints = np.round((np.arange(n_frames) * frame_interval), 2)
|
|
202
|
+
columns = [f"ROI_{i}" for i in range(n_rois)]
|
|
203
|
+
|
|
204
|
+
df = pd.DataFrame(np.round(array, 2), columns=columns, index=timepoints)
|
|
205
|
+
df.index.name = "Time (s)"
|
|
206
|
+
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
|
207
|
+
df.to_csv(filepath, index=True)
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Copyright © 2025 Pentabyteman
|
|
3
|
-
"""
|
|
4
|
-
# src/pykrait/__main__.py
|
|
5
|
-
|
|
6
|
-
import sys
|
|
7
|
-
from PySide6.QtWidgets import QApplication
|
|
8
|
-
from pykrait.gui.fileselection import FileSelectionWindow
|
|
9
|
-
from pykrait.gui.mainwindow import MainWindow
|
|
10
|
-
|
|
11
|
-
class AppController:
|
|
12
|
-
def __init__(self):
|
|
13
|
-
self.app = QApplication(sys.argv)
|
|
14
|
-
self.file_selection = FileSelectionWindow()
|
|
15
|
-
self.file_selection.analysis_complete.connect(self.on_analysis_complete)
|
|
16
|
-
self.file_selection.show()
|
|
17
|
-
|
|
18
|
-
# Keep references to windows
|
|
19
|
-
self.main_window = None
|
|
20
|
-
|
|
21
|
-
def on_analysis_complete(self, results):
|
|
22
|
-
# Called when analysis is done in FileSelectionWindow
|
|
23
|
-
# Hide file selection window
|
|
24
|
-
self.file_selection.close()
|
|
25
|
-
# Launch main window, keep reference
|
|
26
|
-
self.main_window = MainWindow(
|
|
27
|
-
frames=results["frames"],
|
|
28
|
-
mask=results["masks"],
|
|
29
|
-
mean_intensities=results["mean_intensities"],
|
|
30
|
-
analysis_output=results["analysis_output"],
|
|
31
|
-
analysis_params=results["analysis_parameters"]
|
|
32
|
-
)
|
|
33
|
-
self.main_window.show()
|
|
34
|
-
|
|
35
|
-
def run(self):
|
|
36
|
-
sys.exit(self.app.exec())
|
|
37
|
-
|
|
38
|
-
# In __main__.py
|
|
39
|
-
if __name__ == "__main__":
|
|
40
|
-
controller = AppController()
|
|
41
|
-
controller.run()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|