pykrait 0.2.1__tar.gz → 0.2.3__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.
Files changed (25) hide show
  1. {pykrait-0.2.1 → pykrait-0.2.3}/PKG-INFO +9 -2
  2. {pykrait-0.2.1 → pykrait-0.2.3}/README.md +6 -1
  3. {pykrait-0.2.1 → pykrait-0.2.3}/pyproject.toml +13 -7
  4. pykrait-0.2.3/src/pykrait/__main__.py +9 -0
  5. pykrait-0.2.3/src/pykrait/gui/app.py +69 -0
  6. {pykrait-0.2.1 → pykrait-0.2.3}/src/pykrait/gui/async_workers.py +98 -7
  7. {pykrait-0.2.1 → pykrait-0.2.3}/src/pykrait/gui/fileselection.py +2 -2
  8. {pykrait-0.2.1 → pykrait-0.2.3}/src/pykrait/gui/mainwindow.py +57 -68
  9. pykrait-0.2.3/src/pykrait/gui/resources/logo.png +0 -0
  10. {pykrait-0.2.1 → pykrait-0.2.3}/src/pykrait/io/io.py +98 -4
  11. pykrait-0.2.1/src/pykrait/__main__.py +0 -41
  12. {pykrait-0.2.1 → pykrait-0.2.3}/.gitignore +0 -0
  13. {pykrait-0.2.1 → pykrait-0.2.3}/LICENSE +0 -0
  14. {pykrait-0.2.1 → pykrait-0.2.3}/src/.python-version +0 -0
  15. {pykrait-0.2.1 → pykrait-0.2.3}/src/pykrait/__init__.py +0 -0
  16. {pykrait-0.2.1 → pykrait-0.2.3}/src/pykrait/io/__init__.py +0 -0
  17. {pykrait-0.2.1 → pykrait-0.2.3}/src/pykrait/pipeline/__init__.py +0 -0
  18. {pykrait-0.2.1 → pykrait-0.2.3}/src/pykrait/pipeline/pipeline.py +0 -0
  19. {pykrait-0.2.1 → pykrait-0.2.3}/src/pykrait/preprocessing/__init__.py +0 -0
  20. {pykrait-0.2.1 → pykrait-0.2.3}/src/pykrait/preprocessing/segmentation.py +0 -0
  21. {pykrait-0.2.1 → pykrait-0.2.3}/src/pykrait/preprocessing/timeseries_extraction.py +0 -0
  22. {pykrait-0.2.1 → pykrait-0.2.3}/src/pykrait/trace_analysis/__init__.py +0 -0
  23. {pykrait-0.2.1 → pykrait-0.2.3}/src/pykrait/trace_analysis/filtering.py +0 -0
  24. {pykrait-0.2.1 → pykrait-0.2.3}/src/pykrait/trace_analysis/oscillations.py +0 -0
  25. {pykrait-0.2.1 → pykrait-0.2.3}/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.1
3
+ Version: 0.2.3
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
@@ -59,13 +61,18 @@ Description-Content-Type: text/markdown
59
61
  pip install pykrait
60
62
  ```
61
63
 
64
+ To use the GUI, install it with
65
+ ```bash
66
+ pip install 'pykrait[gui]'
67
+ ```
68
+
62
69
  ## Documentation
63
70
 
64
71
  See the [docs](docs/) folder for detailed guides and API reference.
65
72
 
66
73
  ## License
67
74
 
68
- This project is licensed under the GNU Lesser General Public License (GLP). See the [LICENSE](LICENSE) file for details.
75
+ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
69
76
 
70
77
  ## Contributing
71
78
 
@@ -12,13 +12,18 @@
12
12
  pip install pykrait
13
13
  ```
14
14
 
15
+ To use the GUI, install it with
16
+ ```bash
17
+ pip install 'pykrait[gui]'
18
+ ```
19
+
15
20
  ## Documentation
16
21
 
17
22
  See the [docs](docs/) folder for detailed guides and API reference.
18
23
 
19
24
  ## License
20
25
 
21
- This project is licensed under the GNU Lesser General Public License (GLP). See the [LICENSE](LICENSE) file for details.
26
+ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
22
27
 
23
28
  ## Contributing
24
29
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pykrait"
3
- version = "0.2.1"
3
+ version = "0.2.3"
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",
@@ -56,22 +58,26 @@ dev = [
56
58
  requires = ["hatchling"]
57
59
  build-backend = "hatchling.build"
58
60
 
59
- [tool.hatch.build.targets.sdist]
60
- include = ["src/**", "pyproject.toml", "README.md"]
61
+ [tool.hatch.metadata]
62
+ allow-direct-references = true
61
63
 
62
64
  [tool.hatch.build.targets.wheel]
63
65
  only-include = ["src"]
66
+ packages = ["src/pykrait"]
64
67
 
65
- [tool.hatch.metadata]
66
- allow-direct-references = true
68
+ [tool.hatch.build.targets.sdist]
69
+ include = ["src/**", "README.md", "pyproject.toml"]
67
70
 
68
71
  [tool.pytest.ini_options]
69
72
  pythonpath = ["src"]
70
73
  testpaths = ["tests"]
71
74
 
72
75
  [tool.coverage.run]
73
- source = ["stavromueller_beta"]
76
+ source = ["pykrait"]
74
77
 
75
78
  [tool.coverage.report]
76
79
  show_missing = true
77
80
  skip_covered = true
81
+
82
+ [tool.setuptools.package-data]
83
+ "pykrait.gui.resources" = ["logo.png"]
@@ -0,0 +1,9 @@
1
+ """
2
+ Copyright © 2025 Pentabyteman
3
+ """
4
+ # src/pykrait/__main__.py
5
+ from pykrait.gui.app import AppController
6
+
7
+ if __name__ == "__main__":
8
+ controller = AppController()
9
+ controller.run()
@@ -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 pykrait.io.io import load_timelapse_lazy
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 provided in the analysis parameters, and no frame interval could be inferred from the metadata. Defaulting to 1 second.")
46
- self.analysis_output.frame_interval = 1 # Default to 1 second if not provided
47
- elif self.analysis_parameters.frame_interval is None and frame_interval is not None:
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 is not None and self.analysis_parameters.frame_interval != 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 = tifffile.imread(self.analysis_output.masks_path)
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="Model Files (*.npy *.pkl *.pt *.onnx);;All Files (*)"
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 = get_pixel_contour_from_label_img(label_img=self.mask, orig_shape=self.mask.shape, target_shape=(512, 512))
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[0]) * 100
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
- if self.analysis_output is None or self.analysis_params is None:
537
- QMessageBox.warning(self, "Save Failed", "No analysis data to save.")
538
- return
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
- try:
541
- # Derive save paths
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()
@@ -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, PackageNotFoundError
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