waveorder 2.2.1b0__py3-none-any.whl → 3.0.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.
Files changed (58) hide show
  1. waveorder/_version.py +16 -3
  2. waveorder/acq/__init__.py +0 -0
  3. waveorder/acq/acq_functions.py +166 -0
  4. waveorder/assets/HSV_legend.png +0 -0
  5. waveorder/assets/JCh_legend.png +0 -0
  6. waveorder/assets/waveorder_plugin_logo.png +0 -0
  7. waveorder/calib/Calibration.py +1512 -0
  8. waveorder/calib/Optimization.py +470 -0
  9. waveorder/calib/__init__.py +0 -0
  10. waveorder/calib/calibration_workers.py +464 -0
  11. waveorder/cli/apply_inverse_models.py +328 -0
  12. waveorder/cli/apply_inverse_transfer_function.py +379 -0
  13. waveorder/cli/compute_transfer_function.py +432 -0
  14. waveorder/cli/gui_widget.py +58 -0
  15. waveorder/cli/main.py +39 -0
  16. waveorder/cli/monitor.py +163 -0
  17. waveorder/cli/option_eat_all.py +47 -0
  18. waveorder/cli/parsing.py +122 -0
  19. waveorder/cli/printing.py +16 -0
  20. waveorder/cli/reconstruct.py +67 -0
  21. waveorder/cli/settings.py +187 -0
  22. waveorder/cli/utils.py +175 -0
  23. waveorder/filter.py +1 -2
  24. waveorder/focus.py +136 -25
  25. waveorder/io/__init__.py +0 -0
  26. waveorder/io/_reader.py +61 -0
  27. waveorder/io/core_functions.py +272 -0
  28. waveorder/io/metadata_reader.py +195 -0
  29. waveorder/io/utils.py +175 -0
  30. waveorder/io/visualization.py +160 -0
  31. waveorder/models/inplane_oriented_thick_pol3d_vector.py +3 -3
  32. waveorder/models/isotropic_fluorescent_thick_3d.py +92 -0
  33. waveorder/models/isotropic_fluorescent_thin_3d.py +331 -0
  34. waveorder/models/isotropic_thin_3d.py +73 -72
  35. waveorder/models/phase_thick_3d.py +103 -4
  36. waveorder/napari.yaml +36 -0
  37. waveorder/plugin/__init__.py +9 -0
  38. waveorder/plugin/gui.py +1094 -0
  39. waveorder/plugin/gui.ui +1440 -0
  40. waveorder/plugin/job_manager.py +42 -0
  41. waveorder/plugin/main_widget.py +1605 -0
  42. waveorder/plugin/tab_recon.py +3294 -0
  43. waveorder/scripts/__init__.py +0 -0
  44. waveorder/scripts/launch_napari.py +13 -0
  45. waveorder/scripts/repeat-cal-acq-rec.py +147 -0
  46. waveorder/scripts/repeat-calibration.py +31 -0
  47. waveorder/scripts/samples.py +85 -0
  48. waveorder/scripts/simulate_zarr_acq.py +204 -0
  49. waveorder/util.py +1 -1
  50. waveorder/visuals/napari_visuals.py +1 -1
  51. waveorder-3.0.0.dist-info/METADATA +350 -0
  52. waveorder-3.0.0.dist-info/RECORD +69 -0
  53. {waveorder-2.2.1b0.dist-info → waveorder-3.0.0.dist-info}/WHEEL +1 -1
  54. waveorder-3.0.0.dist-info/entry_points.txt +5 -0
  55. {waveorder-2.2.1b0.dist-info → waveorder-3.0.0.dist-info/licenses}/LICENSE +13 -1
  56. waveorder-2.2.1b0.dist-info/METADATA +0 -187
  57. waveorder-2.2.1b0.dist-info/RECORD +0 -27
  58. {waveorder-2.2.1b0.dist-info → waveorder-3.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,3294 @@
1
+ import datetime
2
+ import json
3
+ import logging
4
+ import os
5
+ import threading
6
+ import time
7
+ import types
8
+ import uuid
9
+ import warnings
10
+ from pathlib import Path
11
+ from typing import (
12
+ TYPE_CHECKING,
13
+ Annotated,
14
+ Any,
15
+ Final,
16
+ List,
17
+ Literal,
18
+ Union,
19
+ get_args,
20
+ get_origin,
21
+ )
22
+
23
+ from iohub.ngff import open_ome_zarr
24
+ from magicgui import widgets
25
+ from magicgui.type_map import get_widget_class
26
+
27
+ # FIXME avoid star import
28
+ # Since we are instantiating GUI widgets/elements based on pydantic model
29
+ # star import provides that flexibility
30
+ from magicgui.widgets import *
31
+ from napari.utils.notifications import show_error, show_info
32
+ from qtpy import QtCore
33
+ from qtpy.QtCore import QEvent, Qt, QThread, Signal
34
+ from qtpy.QtWidgets import *
35
+
36
+ from waveorder.plugin import job_manager
37
+
38
+ if TYPE_CHECKING:
39
+ from napari import Viewer
40
+
41
+ from concurrent.futures import ThreadPoolExecutor
42
+
43
+ from pydantic import BaseModel, NonNegativeInt, ValidationError
44
+ from pydantic_core import PydanticUndefinedType
45
+
46
+ from waveorder.cli.settings import (
47
+ BirefringenceApplyInverseSettings,
48
+ BirefringenceSettings,
49
+ BirefringenceTransferFunctionSettings,
50
+ FluorescenceSettings,
51
+ FluorescenceTransferFunctionSettings,
52
+ FourierApplyInverseSettings,
53
+ PhaseSettings,
54
+ PhaseTransferFunctionSettings,
55
+ )
56
+
57
+ PYDANTIC_CLASSES_DEF = (
58
+ BirefringenceSettings,
59
+ BirefringenceTransferFunctionSettings,
60
+ BirefringenceApplyInverseSettings,
61
+ PhaseSettings,
62
+ PhaseTransferFunctionSettings,
63
+ FluorescenceSettings,
64
+ FluorescenceTransferFunctionSettings,
65
+ FourierApplyInverseSettings,
66
+ )
67
+
68
+ from waveorder.cli import settings
69
+ from waveorder.io import utils
70
+
71
+ MSG_SUCCESS = {"msg": "success"}
72
+
73
+ _validate_alert = "⚠️"
74
+ _validate_ok = "✅"
75
+ _green_dot = "🟢"
76
+ _red_dot = "🔴"
77
+ _info_icon = "ℹ️"
78
+
79
+ # For now replicate CLI processing modes - these could reside in the CLI settings file as well
80
+ # for consistency
81
+ OPTION_TO_MODEL_DICT = {
82
+ "birefringence": {"enabled": False, "setting": None},
83
+ "phase": {"enabled": False, "setting": None},
84
+ "fluorescence": {"enabled": False, "setting": None},
85
+ }
86
+
87
+ CONTAINERS_INFO = {}
88
+
89
+ # This keeps an instance of the MyWorker class
90
+ # napari will not stop processes and the Hide event is not reliable
91
+ HAS_INSTANCE = {"val": False, "instance": None}
92
+
93
+ # Components Queue list for new Jobs spanned from single processing
94
+ NEW_WIDGETS_QUEUE = []
95
+ NEW_WIDGETS_QUEUE_THREADS = []
96
+ MULTI_JOBS_REFS = {}
97
+ ROW_POP_QUEUE = []
98
+
99
+
100
+ def unwrap_optional(ftype: Any) -> Any:
101
+ """Unwrap Optional[X] to get X. Handles both Optional[X] and X | None syntax.
102
+
103
+ Args:
104
+ ftype: A type annotation
105
+
106
+ Returns:
107
+ The unwrapped type, or ftype unchanged if not Optional
108
+ """
109
+ origin = get_origin(ftype)
110
+ if origin is Union or isinstance(ftype, types.UnionType):
111
+ args = [a for a in get_args(ftype) if a is not type(None)]
112
+ if len(args) == 1:
113
+ return args[0]
114
+ return ftype
115
+
116
+
117
+ def is_subclass_of(
118
+ ftype: Any,
119
+ base: type | tuple[type, ...],
120
+ *,
121
+ require_optional: bool = False,
122
+ ) -> bool:
123
+ """Check if ftype (possibly Optional-wrapped) is a subclass of base.
124
+
125
+ Args:
126
+ ftype: A type annotation
127
+ base: The base type(s) to check against (can be tuple for multiple)
128
+ require_optional: If True, only return True if ftype was Optional-wrapped
129
+
130
+ Returns:
131
+ True if the (unwrapped) ftype is a subclass of base
132
+ """
133
+ inner = unwrap_optional(ftype)
134
+ if require_optional and inner is ftype:
135
+ return False
136
+ # Handle Annotated types by unwrapping them
137
+ if get_origin(inner) is Annotated:
138
+ inner = get_args(inner)[0]
139
+ return isinstance(inner, type) and issubclass(inner, base)
140
+
141
+
142
+ # Main class for the Reconstruction tab
143
+ # Not efficient since instantiated from GUI
144
+ # Does not have access to common functions in main_widget
145
+ # ToDo : From main_widget and pass self reference
146
+ class Ui_ReconTab_Form(QWidget):
147
+
148
+ def __init__(self, parent=None, stand_alone=False):
149
+ super().__init__(parent)
150
+ self._ui = parent
151
+ self.stand_alone = stand_alone
152
+ self.viewer: Viewer = None
153
+ if HAS_INSTANCE["val"]:
154
+ self.current_dir_path = str(Path.cwd())
155
+ self.directory = str(Path.cwd())
156
+ self.input_directory = HAS_INSTANCE["input_directory"]
157
+ self.output_directory = HAS_INSTANCE["output_directory"]
158
+ self.model_directory = HAS_INSTANCE["model_directory"]
159
+ self.yaml_model_file = HAS_INSTANCE["yaml_model_file"]
160
+ else:
161
+ self.directory = str(Path.cwd())
162
+ self.current_dir_path = str(Path.cwd())
163
+ self.input_directory = str(Path.cwd())
164
+ self.output_directory = str(Path.cwd())
165
+ self.model_directory = str(Path.cwd())
166
+ self.yaml_model_file = str(Path.cwd())
167
+
168
+ self.input_directory_dataset = None
169
+ self.input_directory_datasetMeta = None
170
+ self.input_channel_names = []
171
+
172
+ # Parent (Widget) which holds the GUI ##############################
173
+ self.recon_tab_mainScrollArea = QScrollArea()
174
+ self.recon_tab_mainScrollArea.setWidgetResizable(True)
175
+
176
+ self.recon_tab_widget = QWidget()
177
+ self.recon_tab_widget.setSizePolicy(
178
+ QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
179
+ )
180
+ self.recon_tab_layout = QVBoxLayout()
181
+ self.recon_tab_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop)
182
+ self.recon_tab_layout.setContentsMargins(0, 0, 0, 0)
183
+ self.recon_tab_layout.setSpacing(0)
184
+ self.recon_tab_widget.setLayout(self.recon_tab_layout)
185
+ self.recon_tab_mainScrollArea.setWidget(self.recon_tab_widget)
186
+
187
+ # Top Section Group - Data ##############################
188
+ group_box_Data_groupBox_widget = QGroupBox("Data")
189
+ group_box_Data_layout = QVBoxLayout()
190
+ group_box_Data_layout.setContentsMargins(0, 5, 0, 0)
191
+ group_box_Data_layout.setSpacing(0)
192
+ group_box_Data_groupBox_widget.setLayout(group_box_Data_layout)
193
+
194
+ # Input Data ##############################
195
+ self.data_input_widget = QWidget()
196
+ self.data_input_widget_layout = QHBoxLayout()
197
+ self.data_input_widget_layout.setAlignment(
198
+ QtCore.Qt.AlignmentFlag.AlignTop
199
+ )
200
+ self.data_input_widget.setLayout(self.data_input_widget_layout)
201
+
202
+ self.data_input_Label = widgets.Label(value="Input Store")
203
+ # self.data_input_Label.native.setMinimumWidth(97)
204
+ self.data_input_LineEdit = widgets.LineEdit(value=self.input_directory)
205
+ self.data_input_PushButton = widgets.PushButton(label="Browse")
206
+ # self.data_input_PushButton.native.setMinimumWidth(75)
207
+ self.data_input_PushButton.clicked.connect(self.browse_dir_path_input)
208
+ self.data_input_LineEdit.changed.connect(
209
+ self.read_and_set_input_path_on_validation
210
+ )
211
+
212
+ self.data_input_widget_layout.addWidget(self.data_input_Label.native)
213
+ self.data_input_widget_layout.addWidget(
214
+ self.data_input_LineEdit.native
215
+ )
216
+ self.data_input_widget_layout.addWidget(
217
+ self.data_input_PushButton.native
218
+ )
219
+
220
+ # Output Data ##############################
221
+ self.data_output_widget = QWidget()
222
+ self.data_output_widget_layout = QHBoxLayout()
223
+ self.data_output_widget_layout.setAlignment(
224
+ QtCore.Qt.AlignmentFlag.AlignTop
225
+ )
226
+ self.data_output_widget.setLayout(self.data_output_widget_layout)
227
+
228
+ self.data_output_Label = widgets.Label(value="Output Directory")
229
+ self.data_output_LineEdit = widgets.LineEdit(
230
+ value=self.output_directory
231
+ )
232
+ self.data_output_PushButton = widgets.PushButton(label="Browse")
233
+ # self.data_output_PushButton.native.setMinimumWidth(75)
234
+ self.data_output_PushButton.clicked.connect(
235
+ self.browse_dir_path_output
236
+ )
237
+ self.data_output_LineEdit.changed.connect(
238
+ self.read_and_set_out_path_on_validation
239
+ )
240
+
241
+ self.data_output_widget_layout.addWidget(self.data_output_Label.native)
242
+ self.data_output_widget_layout.addWidget(
243
+ self.data_output_LineEdit.native
244
+ )
245
+ self.data_output_widget_layout.addWidget(
246
+ self.data_output_PushButton.native
247
+ )
248
+
249
+ self.data_input_Label.native.setMinimumWidth(115)
250
+ self.data_output_Label.native.setMinimumWidth(115)
251
+
252
+ group_box_Data_layout.addWidget(self.data_input_widget)
253
+ group_box_Data_layout.addWidget(self.data_output_widget)
254
+ self.recon_tab_layout.addWidget(group_box_Data_groupBox_widget)
255
+
256
+ ##################################
257
+
258
+ # Middle Section - Models ##############################
259
+ # Selection modes, New, Load, Clear
260
+ # Pydantic Models ScrollArea
261
+
262
+ group_box_Models_groupBox_widget = QGroupBox("Models")
263
+ group_box_Models_layout = QVBoxLayout()
264
+ group_box_Models_layout.setContentsMargins(0, 5, 0, 0)
265
+ group_box_Models_layout.setSpacing(0)
266
+ group_box_Models_groupBox_widget.setLayout(group_box_Models_layout)
267
+
268
+ self.models_widget = QWidget()
269
+ self.models_widget_layout = QHBoxLayout()
270
+ self.models_widget_layout.setAlignment(
271
+ QtCore.Qt.AlignmentFlag.AlignTop
272
+ )
273
+ self.models_widget.setLayout(self.models_widget_layout)
274
+
275
+ self.modes_selected = OPTION_TO_MODEL_DICT.copy()
276
+
277
+ # Make a copy of the Reconstruction settings mode, these will be used as template
278
+ for mode in self.modes_selected.keys():
279
+ self.modes_selected[mode]["setting"] = None
280
+
281
+ # Checkboxes for the modes to select single or combination of modes
282
+ for mode in self.modes_selected.keys():
283
+ self.modes_selected[mode]["Checkbox"] = widgets.Checkbox(
284
+ name=mode, label=mode
285
+ )
286
+ self.models_widget_layout.addWidget(
287
+ self.modes_selected[mode]["Checkbox"].native
288
+ )
289
+
290
+ # PushButton to create a copy of the model - UI
291
+ self.models_new_PushButton = widgets.PushButton(label="New")
292
+ # self.models_new_PushButton.native.setMinimumWidth(100)
293
+ self.models_new_PushButton.clicked.connect(self.build_acq_contols)
294
+
295
+ self.models_load_PushButton = DropButton(text="Load", recon_tab=self)
296
+ # self.models_load_PushButton.setMinimumWidth(90)
297
+
298
+ # Passing model location label to model location selector
299
+ self.models_load_PushButton.clicked.connect(
300
+ lambda: self.browse_dir_path_model()
301
+ )
302
+
303
+ # PushButton to clear all copies of models that are create for UI
304
+ self.models_clear_PushButton = widgets.PushButton(label="Clear")
305
+ # self.models_clear_PushButton.native.setMinimumWidth(110)
306
+ self.models_clear_PushButton.clicked.connect(self.clear_all_models)
307
+
308
+ self.models_widget_layout.addWidget(self.models_new_PushButton.native)
309
+ self.models_widget_layout.addWidget(self.models_load_PushButton)
310
+ self.models_widget_layout.addWidget(
311
+ self.models_clear_PushButton.native
312
+ )
313
+
314
+ # Middle scrollable component which will hold Editable/(vertical) Expanding UI
315
+ self.models_scrollArea = QScrollArea()
316
+ self.models_scrollArea.setWidgetResizable(True)
317
+ self.models_container_widget = DropWidget(self)
318
+ self.models_container_widget_layout = QVBoxLayout()
319
+ self.models_container_widget_layout.setContentsMargins(0, 0, 0, 0)
320
+ self.models_container_widget_layout.setSpacing(2)
321
+ self.models_container_widget_layout.setAlignment(
322
+ QtCore.Qt.AlignmentFlag.AlignTop
323
+ )
324
+ self.models_container_widget.setLayout(
325
+ self.models_container_widget_layout
326
+ )
327
+ self.models_scrollArea.setWidget(self.models_container_widget)
328
+
329
+ group_box_Models_layout.addWidget(self.models_widget)
330
+ group_box_Models_layout.addWidget(self.models_scrollArea)
331
+
332
+ ##################################
333
+
334
+ # Create the splitter to resize Middle and Bottom Sections if required ##################################
335
+ splitter = QSplitter()
336
+ splitter.setOrientation(Qt.Orientation.Vertical)
337
+ splitter.setSizes([600, 200])
338
+
339
+ self.recon_tab_layout.addWidget(splitter)
340
+
341
+ # Reconstruction ##################################
342
+ # Run, Processing, On-The-Fly
343
+ group_box_Reconstruction_groupBox_widget = QGroupBox(
344
+ "Reconstruction Queue"
345
+ )
346
+ group_box_Reconstruction_layout = QVBoxLayout()
347
+ group_box_Reconstruction_layout.setContentsMargins(5, 10, 5, 5)
348
+ group_box_Reconstruction_layout.setSpacing(2)
349
+ group_box_Reconstruction_groupBox_widget.setLayout(
350
+ group_box_Reconstruction_layout
351
+ )
352
+
353
+ splitter.addWidget(group_box_Models_groupBox_widget)
354
+ splitter.addWidget(group_box_Reconstruction_groupBox_widget)
355
+
356
+ my_splitter_handle = splitter.handle(1)
357
+ my_splitter_handle.setStyleSheet("background: 1px rgb(128,128,128);")
358
+ splitter.setStyleSheet(
359
+ """QSplitter::handle:pressed {background-color: #ca5;}"""
360
+ )
361
+
362
+ # PushButton to validate and Run the yaml file(s) based on selection against the Input store
363
+ self.reconstruction_run_PushButton = widgets.PushButton(
364
+ name="RUN Model"
365
+ )
366
+ self.reconstruction_run_PushButton.native.setMinimumWidth(100)
367
+ self.reconstruction_run_PushButton.clicked.connect(
368
+ self.build_model_and_run
369
+ )
370
+
371
+ group_box_Reconstruction_layout.addWidget(
372
+ self.reconstruction_run_PushButton.native
373
+ )
374
+
375
+ # Tabs - Processing & On-The-Fly
376
+ tabs_Reconstruction = QTabWidget()
377
+ group_box_Reconstruction_layout.addWidget(tabs_Reconstruction)
378
+
379
+ # Table for Jobs processing entries
380
+ tab1_processing_widget = QWidget()
381
+ tab1_processing_widget_layout = QVBoxLayout()
382
+ tab1_processing_widget_layout.setContentsMargins(5, 5, 5, 5)
383
+ tab1_processing_widget_layout.setSpacing(2)
384
+ tab1_processing_widget.setLayout(tab1_processing_widget_layout)
385
+ self.proc_table_QFormLayout = QFormLayout()
386
+ self.proc_table_QFormLayout.setAlignment(
387
+ QtCore.Qt.AlignmentFlag.AlignTop
388
+ )
389
+ tab1_processing_form_widget = QWidget()
390
+ tab1_processing_form_widget.setSizePolicy(
391
+ QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
392
+ )
393
+ tab1_processing_form_widget.setLayout(self.proc_table_QFormLayout)
394
+ tab1_processing_widget_layout.addWidget(tab1_processing_form_widget)
395
+
396
+ _clear_results_btn = widgets.PushButton(label="Clear Results")
397
+ _clear_results_btn.clicked.connect(self.clear_results_table)
398
+ tab1_processing_widget_layout.addWidget(_clear_results_btn.native)
399
+
400
+ # Table for On-The-Fly processing entries
401
+ tab2_processing_widget = QWidget()
402
+ tab2_processing_widget_layout = QVBoxLayout()
403
+ tab2_processing_widget_layout.setContentsMargins(0, 0, 0, 0)
404
+ tab2_processing_widget_layout.setSpacing(0)
405
+ tab2_processing_widget.setLayout(tab2_processing_widget_layout)
406
+ self.proc_OTF_table_QFormLayout = QFormLayout()
407
+ self.proc_OTF_table_QFormLayout.setAlignment(
408
+ QtCore.Qt.AlignmentFlag.AlignTop
409
+ )
410
+ _proc_OTF_table_widget = QWidget()
411
+ _proc_OTF_table_widget.setSizePolicy(
412
+ QSizePolicy.Expanding, QSizePolicy.Expanding
413
+ )
414
+ _proc_OTF_table_widget.setLayout(self.proc_OTF_table_QFormLayout)
415
+ tab2_processing_widget_layout.addWidget(_proc_OTF_table_widget)
416
+ tab2_processing_widget.setMaximumHeight(100)
417
+
418
+ tabs_Reconstruction.addTab(tab1_processing_widget, "Processing")
419
+ tabs_Reconstruction.addTab(tab2_processing_widget, "On-The-Fly")
420
+
421
+ # Editable List holding pydantic class(es) as per user selection
422
+ self.pydantic_classes = list()
423
+ self.prev_model_settings = {}
424
+ self.index = 0
425
+ self.pollData = False
426
+
427
+ # Stores Model & Components values which cause validation failure - can be highlighted on the model field as Red
428
+ self.modelHighlighterVals = {}
429
+
430
+ self.job_manager = job_manager.JobManager()
431
+
432
+ # handle napari's close widget and avoid starting a second instance
433
+ if HAS_INSTANCE["val"]:
434
+ self.worker: MyWorker = HAS_INSTANCE["MyWorker"]
435
+ self.worker.set_new_instances(
436
+ self.proc_table_QFormLayout, self, self._ui
437
+ )
438
+ else:
439
+ self.worker = MyWorker(self.proc_table_QFormLayout, self, self._ui)
440
+ HAS_INSTANCE["val"] = True
441
+ HAS_INSTANCE["MyWorker"] = self.worker
442
+
443
+ self.app = QApplication.instance()
444
+ self.app.lastWindowClosed.connect(
445
+ self.myCloseEvent
446
+ ) # this line is connection to signal close
447
+
448
+ ######################################################
449
+
450
+ # our defined close event since napari doesnt do
451
+ def myCloseEvent(self):
452
+ event = QEvent(QEvent.Type.Close)
453
+ self.closeEvent(event)
454
+ # self.app.exit()
455
+
456
+ # on napari close - cleanup
457
+ def closeEvent(self, event):
458
+ if event.type() == QEvent.Type.Close:
459
+ pass
460
+
461
+ def hideEvent(self, event):
462
+ if event.type() == QEvent.Type.Hide and (
463
+ hasattr(self, "_ui")
464
+ and self._ui is not None
465
+ and self._ui.isVisible()
466
+ ):
467
+ pass
468
+
469
+ def showEvent(self, event):
470
+ if event.type() == QEvent.Type.Show:
471
+ pass
472
+
473
+ def set_viewer(self, viewer):
474
+ self.viewer = viewer
475
+
476
+ def show_dataset(self, data_path):
477
+ # Show reconstruction data
478
+ try:
479
+ if self.viewer is not None:
480
+ self.viewer.open(data_path, plugin="napari-ome-zarr")
481
+ except Exception as exc:
482
+ self.message_box(exc.args)
483
+
484
+ def confirm_dialog(self, msg="Confirm your selection ?"):
485
+ qm = QMessageBox
486
+ ret = qm.question(
487
+ self.recon_tab_widget,
488
+ "Confirm",
489
+ msg,
490
+ qm.Yes | qm.No,
491
+ )
492
+ if ret == qm.Yes:
493
+ return True
494
+ else:
495
+ return False
496
+
497
+ # Copied from main_widget
498
+ # ToDo: utilize common functions
499
+ # Input data selector
500
+ def browse_dir_path_input(self):
501
+ if len(self.pydantic_classes) > 0 and not self.confirm_dialog(
502
+ "Changing Input Data will reset your models. Continue ?"
503
+ ):
504
+ return
505
+ else:
506
+ self.clear_all_models(silent=True)
507
+ try:
508
+ result = self.open_file_dialog(
509
+ self.input_directory, "dir", filter="ZARR Storage (*.zarr)"
510
+ )
511
+ # .zarr is a folder but we could implement a filter to scan for "ending with" and present those if required
512
+ except Exception as exc:
513
+ self.message_box(exc.args)
514
+ return
515
+
516
+ if result == "":
517
+ return
518
+
519
+ self.data_input_LineEdit.value = result
520
+
521
+ def browse_dir_path_output(self):
522
+ try:
523
+ result = self.open_file_dialog(self.output_directory, "dir")
524
+ except Exception as exc:
525
+ self.message_box(exc.args)
526
+ return
527
+
528
+ if result == "":
529
+ return
530
+
531
+ if not Path(result).exists():
532
+ self.message_box("Output Directory path must exist !")
533
+ return
534
+
535
+ self.data_output_LineEdit.value = result
536
+
537
+ def browse_dir_path_inputBG(self, elem):
538
+ result = self.open_file_dialog(self.directory, "dir")
539
+ if result == "":
540
+ return
541
+
542
+ ret, ret_msg = self.validate_input_data(result, BG=True)
543
+ if not ret:
544
+ self.message_box(ret_msg)
545
+ return
546
+
547
+ elem.value = result
548
+
549
+ def validate_input_data(
550
+ self, input_data_folder: str, metadata=False, BG=False
551
+ ) -> bool:
552
+ try:
553
+ self.input_channel_names = []
554
+ self.data_input_Label.value = "Input Store"
555
+ input_paths = Path(input_data_folder)
556
+ with open_ome_zarr(input_paths, mode="r") as dataset:
557
+ try:
558
+ self.input_channel_names = dataset.channel_names
559
+ self.data_input_Label.value = (
560
+ "Input Store" + " " + _info_icon
561
+ )
562
+ self.data_input_Label.tooltip = (
563
+ "Channel Names:\n- "
564
+ + "\n- ".join(self.input_channel_names)
565
+ )
566
+ except Exception as exc:
567
+ print(exc.args)
568
+
569
+ try:
570
+ string_pos = []
571
+ i = 0
572
+ for pos_paths, pos in dataset.positions():
573
+ string_pos.append(pos_paths)
574
+ if i == 0:
575
+ axes = pos.zgroup.attrs["multiscales"][0]["axes"]
576
+ string_array_n = [str(x["name"]) for x in axes]
577
+ string_array = [
578
+ str(x)
579
+ for x in pos.zgroup.attrs["multiscales"][0][
580
+ "datasets"
581
+ ][0]["coordinateTransformations"][0]["scale"]
582
+ ]
583
+ string_scale = []
584
+ for i in range(len(string_array_n)):
585
+ string_scale.append(
586
+ "{n}={d}".format(
587
+ n=string_array_n[i], d=string_array[i]
588
+ )
589
+ )
590
+ txt = "\n\nScale: " + ", ".join(string_scale)
591
+ self.data_input_Label.tooltip += txt
592
+ i += 1
593
+ txt = "\n\nFOV: " + ", ".join(string_pos)
594
+ self.data_input_Label.tooltip += txt
595
+ except Exception as exc:
596
+ print(exc.args)
597
+
598
+ if not BG and metadata:
599
+ self.input_directory_dataset = dataset
600
+
601
+ if not BG:
602
+ self.pollData = False
603
+ zattrs = dataset.zattrs
604
+ if self.is_dataset_acq_running(zattrs):
605
+ if self.confirm_dialog(
606
+ msg="This seems like an in-process Acquisition. Would you like to process data on-the-fly ?"
607
+ ):
608
+ self.pollData = True
609
+
610
+ return True, MSG_SUCCESS
611
+ raise Exception(
612
+ "Dataset does not appear to be a valid ome-zarr storage"
613
+ )
614
+ except Exception as exc:
615
+ return False, exc.args
616
+
617
+ # call back for input LineEdit path changed manually
618
+ # include data validation
619
+ def read_and_set_input_path_on_validation(self):
620
+ if (
621
+ self.data_input_LineEdit.value is None
622
+ or len(self.data_input_LineEdit.value) == 0
623
+ ):
624
+ self.data_input_LineEdit.value = self.input_directory
625
+ self.message_box("Input data path cannot be empty")
626
+ return
627
+ if not Path(self.data_input_LineEdit.value).exists():
628
+ self.data_input_LineEdit.value = self.input_directory
629
+ self.message_box("Input data path must point to a valid location")
630
+ return
631
+
632
+ result = self.data_input_LineEdit.value
633
+ valid, ret_msg = self.validate_input_data(result)
634
+
635
+ if valid:
636
+ self.directory = Path(result).parent.absolute()
637
+ self.current_dir_path = result
638
+ self.input_directory = result
639
+
640
+ self.prev_model_settings = {}
641
+
642
+ self.save_last_paths()
643
+ else:
644
+ self.data_input_LineEdit.value = self.input_directory
645
+ self.message_box(ret_msg)
646
+
647
+ self.data_output_LineEdit.value = Path(
648
+ self.input_directory
649
+ ).parent.absolute()
650
+
651
+ def read_and_set_out_path_on_validation(self):
652
+ if (
653
+ self.data_output_LineEdit.value is None
654
+ or len(self.data_output_LineEdit.value) == 0
655
+ ):
656
+ self.data_output_LineEdit.value = self.output_directory
657
+ self.message_box("Output data path cannot be empty")
658
+ return
659
+ if not Path(self.data_output_LineEdit.value).exists():
660
+ self.data_output_LineEdit.value = self.output_directory
661
+ self.message_box("Output data path must point to a valid location")
662
+ return
663
+
664
+ self.output_directory = self.data_output_LineEdit.value
665
+
666
+ self.validate_model_output_paths()
667
+
668
+ def validate_model_output_paths(self):
669
+ if len(self.pydantic_classes) > 0:
670
+ for model_item in self.pydantic_classes:
671
+ output_LineEdit = model_item["output_LineEdit"]
672
+ output_Button = model_item["output_Button"]
673
+ model_item["output_parent_dir"] = self.output_directory
674
+
675
+ full_out_path = os.path.join(
676
+ Path(self.output_directory).absolute(),
677
+ output_LineEdit.value,
678
+ )
679
+ model_item["output"] = full_out_path
680
+
681
+ save_path_exists = (
682
+ True if Path(full_out_path).exists() else False
683
+ )
684
+ output_LineEdit.label = (
685
+ "" if not save_path_exists else (_validate_alert + " ")
686
+ ) + "Output Data:"
687
+ output_LineEdit.tooltip = (
688
+ ""
689
+ if not save_path_exists
690
+ else (_validate_alert + "Output file exists")
691
+ )
692
+ output_Button.text = (
693
+ "" if not save_path_exists else (_validate_alert + " ")
694
+ ) + "Output Data:"
695
+ output_Button.tooltip = (
696
+ ""
697
+ if not save_path_exists
698
+ else (_validate_alert + "Output file exists")
699
+ )
700
+
701
+ def is_dataset_acq_running(self, zattrs: dict) -> bool:
702
+ """
703
+ Checks the zattrs for CurrentDimensions & FinalDimensions key and tries to figure if
704
+ data acquisition is running
705
+ """
706
+
707
+ required_order = ["time", "position", "z", "channel"]
708
+ if "CurrentDimensions" in zattrs.keys():
709
+ my_dict = zattrs["CurrentDimensions"]
710
+ sorted_dict_acq = {
711
+ k: my_dict[k]
712
+ for k in sorted(my_dict, key=lambda x: required_order.index(x))
713
+ }
714
+ if "FinalDimensions" in zattrs.keys():
715
+ my_dict = zattrs["FinalDimensions"]
716
+ sorted_dict_final = {
717
+ k: my_dict[k]
718
+ for k in sorted(my_dict, key=lambda x: required_order.index(x))
719
+ }
720
+ if sorted_dict_acq != sorted_dict_final:
721
+ return True
722
+ return False
723
+
724
+ # Output data selector
725
+ def browse_model_dir_path_output(self, elem):
726
+ result = self.open_file_dialog(self.output_directory, "save")
727
+ if result == "":
728
+ return
729
+
730
+ save_path_exists = True if Path(result).exists() else False
731
+ elem.label = "Output Data:" + (
732
+ "" if not save_path_exists else (" " + _validate_alert)
733
+ )
734
+ elem.tooltip = "" if not save_path_exists else "Output file exists"
735
+
736
+ elem.value = Path(result).name
737
+
738
+ self.save_last_paths()
739
+
740
+ # call back for output LineEdit path changed manually
741
+ def read_and_set_output_path_on_validation(self, elem1, elem2, save_path):
742
+ if elem1.value is None or len(elem1.value) == 0:
743
+ elem1.value = Path(save_path).name
744
+
745
+ save_path = os.path.join(
746
+ Path(self.output_directory).absolute(), elem1.value
747
+ )
748
+
749
+ save_path_exists = True if Path(save_path).exists() else False
750
+ elem1.label = (
751
+ "" if not save_path_exists else (_validate_alert + " ")
752
+ ) + "Output Data:"
753
+ elem1.tooltip = (
754
+ ""
755
+ if not save_path_exists
756
+ else (_validate_alert + "Output file exists")
757
+ )
758
+ elem2.text = (
759
+ "" if not save_path_exists else (_validate_alert + " ")
760
+ ) + "Output Data:"
761
+ elem2.tooltip = (
762
+ ""
763
+ if not save_path_exists
764
+ else (_validate_alert + "Output file exists")
765
+ )
766
+
767
+ self.save_last_paths()
768
+
769
+ # Copied from main_widget
770
+ # ToDo: utilize common functions
771
+ # Output data selector
772
+ def browse_dir_path_model(self):
773
+ results = self.open_file_dialog(
774
+ self.directory, "files", filter="YAML Files (*.yml)"
775
+ ) # returns list
776
+ if len(results) == 0 or results == "":
777
+ return
778
+
779
+ self.model_directory = str(Path(results[0]).parent.absolute())
780
+ self.directory = self.model_directory
781
+ self.current_dir_path = self.model_directory
782
+
783
+ self.save_last_paths()
784
+ self.open_model_files(results)
785
+
786
+ def open_model_files(self, results: List):
787
+ pydantic_models = list()
788
+ for result in results:
789
+ self.yaml_model_file = result
790
+
791
+ with open(result, "r") as yaml_in:
792
+ yaml_object = utils.yaml.safe_load(
793
+ yaml_in
794
+ ) # yaml_object will be a list or a dict
795
+ jsonString = json.dumps(self.convert(yaml_object))
796
+ json_out = json.loads(jsonString)
797
+ json_dict = dict(json_out)
798
+
799
+ selected_modes = list(OPTION_TO_MODEL_DICT.copy().keys())
800
+ exclude_modes = list(OPTION_TO_MODEL_DICT.copy().keys())
801
+
802
+ for k in range(len(selected_modes) - 1, -1, -1):
803
+ if selected_modes[k] in json_dict.keys():
804
+ exclude_modes.pop(k)
805
+ else:
806
+ selected_modes.pop(k)
807
+
808
+ pruned_pydantic_class, ret_msg = self.build_model(selected_modes)
809
+ if pruned_pydantic_class is None:
810
+ self.message_box(ret_msg)
811
+ return
812
+
813
+ pydantic_model, ret_msg = self.get_model_from_file(
814
+ self.yaml_model_file
815
+ )
816
+ if pydantic_model is None:
817
+ if (
818
+ isinstance(ret_msg, List)
819
+ and len(ret_msg) == 2
820
+ and len(ret_msg[0]["loc"]) == 3
821
+ and ret_msg[0]["loc"][2] == "background_path"
822
+ ):
823
+ pydantic_model = pruned_pydantic_class # if only background_path fails validation
824
+ if "birefringence" in json_dict.keys():
825
+ json_dict["birefringence"]["apply_inverse"][
826
+ "background_path"
827
+ ] = ""
828
+ else:
829
+ bg_loc = ""
830
+ self.message_box(
831
+ "background_path:\nPath was invalid and will be reset"
832
+ )
833
+ else:
834
+ self.message_box(ret_msg)
835
+ return
836
+ else:
837
+ # make sure "background_path" is valid
838
+ if "birefringence" in json_dict.keys():
839
+ bg_loc = json_dict["birefringence"]["apply_inverse"][
840
+ "background_path"
841
+ ]
842
+ else:
843
+ bg_loc = ""
844
+ if bg_loc != "":
845
+ extension = os.path.splitext(bg_loc)[1]
846
+ if len(extension) > 0:
847
+ bg_loc = Path(
848
+ os.path.join(
849
+ str(Path(bg_loc).parent.absolute()),
850
+ "background.zarr",
851
+ )
852
+ )
853
+ else:
854
+ bg_loc = Path(os.path.join(bg_loc, "background.zarr"))
855
+ if not bg_loc.exists() or not self.validate_input_data(
856
+ str(bg_loc)
857
+ ):
858
+ self.message_box(
859
+ "background_path:\nPwas invalid and will be reset"
860
+ )
861
+ json_dict["birefringence"]["apply_inverse"][
862
+ "background_path"
863
+ ] = ""
864
+ else:
865
+ json_dict["birefringence"]["apply_inverse"][
866
+ "background_path"
867
+ ] = str(bg_loc.parent.absolute())
868
+
869
+ pydantic_model = self.create_acq_contols2(
870
+ selected_modes, exclude_modes, pydantic_model, json_dict
871
+ )
872
+ if pydantic_model is None:
873
+ self.message_box("Error - pydantic model returned None")
874
+ return
875
+
876
+ pydantic_models.append(pydantic_model)
877
+
878
+ return pydantic_models
879
+
880
+ # useful when using close widget and not napari close and we might need them again
881
+ def save_last_paths(self):
882
+ HAS_INSTANCE["current_dir_path"] = self.current_dir_path
883
+ HAS_INSTANCE["input_directory"] = self.input_directory
884
+ HAS_INSTANCE["output_directory"] = self.output_directory
885
+ HAS_INSTANCE["model_directory"] = self.model_directory
886
+ HAS_INSTANCE["yaml_model_file"] = self.yaml_model_file
887
+
888
+ # clears the results table
889
+ def clear_results_table(self):
890
+ index = self.proc_table_QFormLayout.rowCount()
891
+ if index < 1:
892
+ self.message_box("There are no processing results to clear !")
893
+ return
894
+ if self.confirm_dialog():
895
+ for i in range(self.proc_table_QFormLayout.rowCount()):
896
+ self.proc_table_QFormLayout.removeRow(0)
897
+
898
+ def remove_row(self, row, expID):
899
+ try:
900
+ if row < self.proc_table_QFormLayout.rowCount():
901
+ widgetItem = self.proc_table_QFormLayout.itemAt(row)
902
+ if widgetItem is not None:
903
+ name_widget = widgetItem.widget()
904
+ toolTip_string = str(name_widget.toolTip)
905
+ if expID in toolTip_string:
906
+ self.proc_table_QFormLayout.removeRow(
907
+ row
908
+ ) # removeRow vs takeRow for threads ?
909
+ except Exception as exc:
910
+ print(exc.args)
911
+
912
+ # marks fields on the Model that cause a validation error
913
+ def model_highlighter(self, errs):
914
+ try:
915
+ for uid in errs.keys():
916
+ self.modelHighlighterVals[uid] = {}
917
+ container = errs[uid]["cls"]
918
+ self.modelHighlighterVals[uid]["errs"] = errs[uid]["errs"]
919
+ self.modelHighlighterVals[uid]["items"] = []
920
+ self.modelHighlighterVals[uid]["tooltip"] = []
921
+ if len(errs[uid]["errs"]) > 0:
922
+ self.model_highlighter_setter(
923
+ errs[uid]["errs"], container, uid
924
+ )
925
+ except Exception as exc:
926
+ print(exc.args)
927
+ # more of a test feature - no need to show up
928
+
929
+ # format all model errors into a display format for napari error message box
930
+ def format_string_for_error_display(self, errs):
931
+ try:
932
+ ret_str = ""
933
+ for uid in errs.keys():
934
+ if len(errs[uid]["errs"]) > 0:
935
+ ret_str += errs[uid]["collapsibleBox"] + "\n"
936
+ for idx in range(len(errs[uid]["errs"])):
937
+ ret_str += f"{'>'.join(errs[uid]['errs'][idx]['loc'])}:\n{errs[uid]['errs'][idx]['msg']} \n"
938
+ ret_str += "\n"
939
+ return ret_str
940
+ except Exception as exc:
941
+ return ret_str
942
+
943
+ # recursively fix the container for highlighting
944
+ def model_highlighter_setter(
945
+ self, errs, container: Container, containerID, lev=0
946
+ ):
947
+ try:
948
+ layout = container.native.layout()
949
+ for i in range(layout.count()):
950
+ item = layout.itemAt(i)
951
+ if item.widget():
952
+ widget = layout.itemAt(i).widget()
953
+ if (
954
+ (
955
+ not isinstance(widget._magic_widget, CheckBox)
956
+ and not isinstance(
957
+ widget._magic_widget, PushButton
958
+ )
959
+ )
960
+ and not isinstance(widget._magic_widget, LineEdit)
961
+ and isinstance(
962
+ widget._magic_widget._inner_widget, Container
963
+ )
964
+ and not (widget._magic_widget._inner_widget is None)
965
+ ):
966
+ self.model_highlighter_setter(
967
+ errs,
968
+ widget._magic_widget._inner_widget,
969
+ containerID,
970
+ lev + 1,
971
+ )
972
+ else:
973
+ for idx in range(len(errs)):
974
+ if len(errs[idx]["loc"]) - 1 < lev:
975
+ pass
976
+ elif (
977
+ isinstance(widget._magic_widget, CheckBox)
978
+ or isinstance(widget._magic_widget, LineEdit)
979
+ or isinstance(widget._magic_widget, PushButton)
980
+ ):
981
+ if widget._magic_widget.label == errs[idx][
982
+ "loc"
983
+ ][lev].replace("_", " "):
984
+ if widget._magic_widget.tooltip is None:
985
+ widget._magic_widget.tooltip = "-\n"
986
+ self.modelHighlighterVals[containerID][
987
+ "items"
988
+ ].append(widget._magic_widget)
989
+ self.modelHighlighterVals[containerID][
990
+ "tooltip"
991
+ ].append(widget._magic_widget.tooltip)
992
+ widget._magic_widget.tooltip += (
993
+ errs[idx]["msg"] + "\n"
994
+ )
995
+ widget._magic_widget.native.setStyleSheet(
996
+ "border:1px solid rgb(255, 255, 0); border-width: 1px;"
997
+ )
998
+ elif (
999
+ widget._magic_widget._label_widget.value
1000
+ == errs[idx]["loc"][lev].replace("_", " ")
1001
+ ):
1002
+ if (
1003
+ widget._magic_widget._label_widget.tooltip
1004
+ is None
1005
+ ):
1006
+ widget._magic_widget._label_widget.tooltip = (
1007
+ "-\n"
1008
+ )
1009
+ self.modelHighlighterVals[containerID][
1010
+ "items"
1011
+ ].append(
1012
+ widget._magic_widget._label_widget
1013
+ )
1014
+ self.modelHighlighterVals[containerID][
1015
+ "tooltip"
1016
+ ].append(
1017
+ widget._magic_widget._label_widget.tooltip
1018
+ )
1019
+ widget._magic_widget._label_widget.tooltip += (
1020
+ errs[idx]["msg"] + "\n"
1021
+ )
1022
+ widget._magic_widget._label_widget.native.setStyleSheet(
1023
+ "border:1px solid rgb(255, 255, 0); border-width: 1px;"
1024
+ )
1025
+ if (
1026
+ widget._magic_widget._inner_widget.tooltip
1027
+ is None
1028
+ ):
1029
+ widget._magic_widget._inner_widget.tooltip = (
1030
+ "-\n"
1031
+ )
1032
+ self.modelHighlighterVals[containerID][
1033
+ "items"
1034
+ ].append(
1035
+ widget._magic_widget._inner_widget
1036
+ )
1037
+ self.modelHighlighterVals[containerID][
1038
+ "tooltip"
1039
+ ].append(
1040
+ widget._magic_widget._inner_widget.tooltip
1041
+ )
1042
+ widget._magic_widget._inner_widget.tooltip += (
1043
+ errs[idx]["msg"] + "\n"
1044
+ )
1045
+ widget._magic_widget._inner_widget.native.setStyleSheet(
1046
+ "border:1px solid rgb(255, 255, 0); border-width: 1px;"
1047
+ )
1048
+ except Exception as exc:
1049
+ print(exc.args)
1050
+
1051
+ # recursively fix the container for highlighting
1052
+ def model_reset_highlighter_setter(self):
1053
+ try:
1054
+ for containerID in self.modelHighlighterVals.keys():
1055
+ items = self.modelHighlighterVals[containerID]["items"]
1056
+ tooltip = self.modelHighlighterVals[containerID]["tooltip"]
1057
+ i = 0
1058
+ for widItem in items:
1059
+ widItem.native.setStyleSheet(
1060
+ "border:1px solid rgb(0, 0, 0); border-width: 0px;"
1061
+ )
1062
+ widItem.tooltip = tooltip[i]
1063
+ i += 1
1064
+
1065
+ except Exception as exc:
1066
+ print(exc.args)
1067
+
1068
+ except Exception as exc:
1069
+ print(exc.args)
1070
+
1071
+ # passes msg to napari notifications
1072
+ def message_box(self, msg, type="exc"):
1073
+ if len(msg) > 0:
1074
+ try:
1075
+ json_object = msg
1076
+ json_txt = ""
1077
+ for err in json_object:
1078
+ json_txt = (
1079
+ json_txt
1080
+ + "Loc: {loc}\nMsg:{msg}\nType:{type}\n\n".format(
1081
+ loc=err["loc"], msg=err["msg"], type=err["type"]
1082
+ )
1083
+ )
1084
+ json_txt = str(json_txt)
1085
+ # ToDo: format it better
1086
+ # formatted txt does not show up properly in msg-box ??
1087
+ except (TypeError, KeyError, AttributeError):
1088
+ # msg is not in expected validation error format
1089
+ json_txt = str(msg)
1090
+
1091
+ # show is a message box
1092
+ if self.stand_alone:
1093
+ self.message_box_stand_alone(json_txt)
1094
+ else:
1095
+ if type == "exc":
1096
+ show_error(json_txt)
1097
+ else:
1098
+ show_info(json_txt)
1099
+
1100
+ def message_box_stand_alone(self, msg):
1101
+ q = QMessageBox(
1102
+ QMessageBox.Warning,
1103
+ "Message",
1104
+ str(msg),
1105
+ parent=self.recon_tab_widget,
1106
+ )
1107
+ q.setStandardButtons(QMessageBox.StandardButton.Ok)
1108
+ q.setIcon(QMessageBox.Icon.Warning)
1109
+ q.exec_()
1110
+
1111
+ def add_widget(
1112
+ self, parentLayout: QVBoxLayout, expID, jID, table_entry_ID="", pos=""
1113
+ ):
1114
+
1115
+ jID = str(jID)
1116
+ _cancelJobBtntext = "Cancel Job {jID} ({posName})".format(
1117
+ jID=jID, posName=pos
1118
+ )
1119
+ _cancelJobButton = widgets.PushButton(
1120
+ name="JobID", label=_cancelJobBtntext, enabled=True, value=False
1121
+ )
1122
+ _txtForInfoBox = "Updating {id}-{pos}: Please wait... \nJobID assigned: {jID} ".format(
1123
+ id=table_entry_ID, jID=jID, pos=pos
1124
+ )
1125
+ _scrollAreaCollapsibleBoxDisplayWidget = ScrollableLabel(
1126
+ text=_txtForInfoBox
1127
+ )
1128
+
1129
+ _scrollAreaCollapsibleBoxWidgetLayout = QVBoxLayout()
1130
+ _scrollAreaCollapsibleBoxWidgetLayout.setAlignment(
1131
+ QtCore.Qt.AlignmentFlag.AlignTop
1132
+ )
1133
+
1134
+ _scrollAreaCollapsibleBoxWidgetLayout.addWidget(
1135
+ _cancelJobButton.native
1136
+ )
1137
+ _scrollAreaCollapsibleBoxWidgetLayout.addWidget(
1138
+ _scrollAreaCollapsibleBoxDisplayWidget
1139
+ )
1140
+
1141
+ _scrollAreaCollapsibleBoxWidget = QWidget()
1142
+ _scrollAreaCollapsibleBoxWidget.setLayout(
1143
+ _scrollAreaCollapsibleBoxWidgetLayout
1144
+ )
1145
+ _scrollAreaCollapsibleBox = QScrollArea()
1146
+ _scrollAreaCollapsibleBox.setWidgetResizable(True)
1147
+ _scrollAreaCollapsibleBox.setMinimumHeight(300)
1148
+ _scrollAreaCollapsibleBox.setWidget(_scrollAreaCollapsibleBoxWidget)
1149
+
1150
+ _collapsibleBoxWidgetLayout = QVBoxLayout()
1151
+ _collapsibleBoxWidgetLayout.addWidget(_scrollAreaCollapsibleBox)
1152
+
1153
+ _collapsibleBoxWidget = CollapsibleBox(
1154
+ table_entry_ID + " - " + pos
1155
+ ) # tableEntryID, tableEntryShortDesc - should update with processing status
1156
+ _collapsibleBoxWidget.setSizePolicy(
1157
+ QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
1158
+ )
1159
+ _collapsibleBoxWidget.setContentLayout(_collapsibleBoxWidgetLayout)
1160
+
1161
+ parentLayout.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop)
1162
+ parentLayout.addWidget(_collapsibleBoxWidget)
1163
+
1164
+ MULTI_JOBS_REFS[expID + jID] = {}
1165
+ MULTI_JOBS_REFS[expID + jID]["cancelBtn"] = _cancelJobButton
1166
+ MULTI_JOBS_REFS[expID + jID][
1167
+ "infobox"
1168
+ ] = _scrollAreaCollapsibleBoxDisplayWidget
1169
+ NEW_WIDGETS_QUEUE.remove(expID + jID)
1170
+
1171
+ def add_table_entry_job(self, proc_params):
1172
+
1173
+ tableEntryID = proc_params["tableEntryID"]
1174
+ parentLayout: QVBoxLayout = proc_params["parent_layout"]
1175
+
1176
+ _cancelJobButton = widgets.PushButton(
1177
+ name="JobID", label="Cancel Job", value=False, enabled=False
1178
+ )
1179
+ _txtForInfoBox = "Updating {id}: Please wait...".format(
1180
+ id=tableEntryID
1181
+ )
1182
+ _scrollAreaCollapsibleBoxDisplayWidget = ScrollableLabel(
1183
+ text=_txtForInfoBox
1184
+ )
1185
+ _scrollAreaCollapsibleBoxDisplayWidget.setFixedHeight(300)
1186
+
1187
+ proc_params["table_entry_infoBox"] = (
1188
+ _scrollAreaCollapsibleBoxDisplayWidget
1189
+ )
1190
+ proc_params["cancelJobButton"] = _cancelJobButton
1191
+ parentLayout.addWidget(_cancelJobButton.native)
1192
+ parentLayout.addWidget(_scrollAreaCollapsibleBoxDisplayWidget)
1193
+
1194
+ return proc_params
1195
+
1196
+ def add_remove_check_OTF_table_entry(
1197
+ self, OTF_dir_path, bool_msg, do_check=False
1198
+ ):
1199
+ if do_check:
1200
+ try:
1201
+ for row in range(self.proc_OTF_table_QFormLayout.rowCount()):
1202
+ widgetItem = self.proc_OTF_table_QFormLayout.itemAt(row)
1203
+ if widgetItem is not None:
1204
+ name_widget: QWidget = widgetItem.widget()
1205
+ name_string = str(name_widget.objectName())
1206
+ if OTF_dir_path in name_string:
1207
+ for item in name_widget.findChildren(QPushButton):
1208
+ _poll_Stop_PushButton: QPushButton = item
1209
+ return _poll_Stop_PushButton.isChecked()
1210
+ return False
1211
+ except Exception as exc:
1212
+ print(exc.args)
1213
+ return False
1214
+ else:
1215
+ if bool_msg:
1216
+ _poll_otf_label = ScrollableLabel(
1217
+ text=OTF_dir_path + " " + _green_dot
1218
+ )
1219
+ _poll_Stop_PushButton = QPushButton("Stop")
1220
+ _poll_Stop_PushButton.setCheckable(
1221
+ True
1222
+ ) # Make the button checkable
1223
+ _poll_Stop_PushButton.clicked.connect(
1224
+ lambda: self.stop_OTF_push_button_call(
1225
+ _poll_otf_label, OTF_dir_path + " " + _red_dot
1226
+ )
1227
+ )
1228
+
1229
+ _poll_data_widget = QWidget()
1230
+ _poll_data_widget.setObjectName(OTF_dir_path)
1231
+ _poll_data_widget_layout = QHBoxLayout()
1232
+ _poll_data_widget.setLayout(_poll_data_widget_layout)
1233
+ _poll_data_widget_layout.addWidget(_poll_otf_label)
1234
+ _poll_data_widget_layout.addWidget(_poll_Stop_PushButton)
1235
+
1236
+ self.proc_OTF_table_QFormLayout.insertRow(0, _poll_data_widget)
1237
+ else:
1238
+ try:
1239
+ for row in range(
1240
+ self.proc_OTF_table_QFormLayout.rowCount()
1241
+ ):
1242
+ widgetItem = self.proc_OTF_table_QFormLayout.itemAt(
1243
+ row
1244
+ )
1245
+ if widgetItem is not None:
1246
+ name_widget: QWidget = widgetItem.widget()
1247
+ name_string = str(name_widget.objectName())
1248
+ if OTF_dir_path in name_string:
1249
+ self.proc_OTF_table_QFormLayout.removeRow(row)
1250
+ except Exception as exc:
1251
+ print(exc.args)
1252
+
1253
+ def stop_OTF_push_button_call(self, label, txt):
1254
+ _poll_otf_label: QLabel = label
1255
+ _poll_otf_label.setText(txt)
1256
+ self.setDisabled(True)
1257
+
1258
+ # adds processing entry to _qwidgetTabEntry_layout as row item
1259
+ # row item will be purged from table as processing finishes
1260
+ # there could be 3 tabs for this processing table status
1261
+ # Running, Finished, Errored
1262
+ def addTableEntry(
1263
+ self, table_entry_ID, table_entry_short_desc, proc_params
1264
+ ):
1265
+ _scrollAreaCollapsibleBoxWidgetLayout = QVBoxLayout()
1266
+ _scrollAreaCollapsibleBoxWidgetLayout.setAlignment(
1267
+ QtCore.Qt.AlignmentFlag.AlignTop
1268
+ )
1269
+
1270
+ _scrollAreaCollapsibleBoxWidget = QWidget()
1271
+ _scrollAreaCollapsibleBoxWidget.setLayout(
1272
+ _scrollAreaCollapsibleBoxWidgetLayout
1273
+ )
1274
+ _scrollAreaCollapsibleBoxWidget.setSizePolicy(
1275
+ QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
1276
+ )
1277
+
1278
+ _scrollAreaCollapsibleBox = QScrollArea()
1279
+ _scrollAreaCollapsibleBox.setWidgetResizable(True)
1280
+ _scrollAreaCollapsibleBox.setWidget(_scrollAreaCollapsibleBoxWidget)
1281
+ _scrollAreaCollapsibleBox.setMinimumHeight(300)
1282
+ _scrollAreaCollapsibleBox.setSizePolicy(
1283
+ QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
1284
+ )
1285
+
1286
+ _collapsibleBoxWidgetLayout = QVBoxLayout()
1287
+ _collapsibleBoxWidgetLayout.addWidget(_scrollAreaCollapsibleBox)
1288
+
1289
+ _collapsibleBoxWidget = CollapsibleBox(table_entry_ID)
1290
+ _collapsibleBoxWidget.setSizePolicy(
1291
+ QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
1292
+ )
1293
+ _collapsibleBoxWidget.setContentLayout(_collapsibleBoxWidgetLayout)
1294
+
1295
+ _expandingTabEntryWidgetLayout = QVBoxLayout()
1296
+ _expandingTabEntryWidgetLayout.setAlignment(
1297
+ QtCore.Qt.AlignmentFlag.AlignTop
1298
+ )
1299
+ _expandingTabEntryWidgetLayout.addWidget(_collapsibleBoxWidget)
1300
+
1301
+ _expandingTabEntryWidget = QWidget()
1302
+ _expandingTabEntryWidget.toolTip = table_entry_short_desc
1303
+ _expandingTabEntryWidget.setLayout(_expandingTabEntryWidgetLayout)
1304
+ _expandingTabEntryWidget.setSizePolicy(
1305
+ QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
1306
+ )
1307
+
1308
+ proc_params["tableEntryID"] = table_entry_ID
1309
+ proc_params["parent_layout"] = _scrollAreaCollapsibleBoxWidgetLayout
1310
+ proc_params = self.add_table_entry_job(proc_params)
1311
+
1312
+ # instead of adding, insert at 0 to keep latest entry on top
1313
+ self.proc_table_QFormLayout.insertRow(0, _expandingTabEntryWidget)
1314
+
1315
+ proc_params["table_layout"] = self.proc_table_QFormLayout
1316
+ proc_params["table_entry"] = _expandingTabEntryWidget
1317
+
1318
+ self.worker.run_in_pool(proc_params)
1319
+
1320
+ # Builds the model as required
1321
+ def build_model(self, selected_modes):
1322
+ try:
1323
+ birefringence = None
1324
+ phase = None
1325
+ fluorescence = None
1326
+ chNames = ["State0"]
1327
+ exclude_modes = ["birefringence", "phase", "fluorescence"]
1328
+ if "birefringence" in selected_modes and "phase" in selected_modes:
1329
+ birefringence = settings.BirefringenceSettings()
1330
+ phase = settings.PhaseSettings()
1331
+ chNames = ["State0", "State1", "State2", "State3"]
1332
+ exclude_modes = ["fluorescence"]
1333
+ elif "birefringence" in selected_modes:
1334
+ birefringence = settings.BirefringenceSettings()
1335
+ chNames = ["State0", "State1", "State2", "State3"]
1336
+ exclude_modes = ["fluorescence", "phase"]
1337
+ elif "phase" in selected_modes:
1338
+ phase = settings.PhaseSettings()
1339
+ chNames = ["BF"]
1340
+ exclude_modes = ["birefringence", "fluorescence"]
1341
+ elif "fluorescence" in selected_modes:
1342
+ fluorescence = settings.FluorescenceSettings()
1343
+ chNames = ["FL"]
1344
+ exclude_modes = ["birefringence", "phase"]
1345
+
1346
+ model = None
1347
+ try:
1348
+ model = settings.ReconstructionSettings(
1349
+ input_channel_names=chNames,
1350
+ birefringence=birefringence,
1351
+ phase=phase,
1352
+ fluorescence=fluorescence,
1353
+ )
1354
+ except ValidationError as exc:
1355
+ # use v1 and v2 differ for ValidationError - newer one is not caught properly
1356
+ return None, exc.errors()
1357
+
1358
+ model = self.fix_model(
1359
+ model, exclude_modes, "input_channel_names", chNames
1360
+ )
1361
+ return model, "+".join(selected_modes) + ": MSG_SUCCESS"
1362
+
1363
+ except Exception as exc:
1364
+ return None, exc.args
1365
+
1366
+ def fix_model(self, model, exclude_modes, attr_key, attr_val):
1367
+ """Update model attribute while excluding specified modes."""
1368
+ try:
1369
+ data = model.model_dump(exclude=set(exclude_modes))
1370
+ data[attr_key] = attr_val
1371
+ return settings.ReconstructionSettings.model_validate(data)
1372
+ except ValidationError as exc:
1373
+ logging.error(f"fix_model failed: {exc}")
1374
+ return None
1375
+
1376
+ # Creates UI controls from model based on selections
1377
+ def build_acq_contols(self):
1378
+
1379
+ # Make a copy of selections and unsed for deletion
1380
+ selected_modes = []
1381
+ exclude_modes = []
1382
+
1383
+ for mode in self.modes_selected.keys():
1384
+ enabled = self.modes_selected[mode]["Checkbox"].value
1385
+ if not enabled:
1386
+ exclude_modes.append(mode)
1387
+ else:
1388
+ selected_modes.append(mode)
1389
+
1390
+ self.create_acq_contols2(selected_modes, exclude_modes)
1391
+
1392
+ def create_acq_contols2(
1393
+ self,
1394
+ selected_modes,
1395
+ exclude_modes,
1396
+ my_loaded_model=None,
1397
+ json_dict=None,
1398
+ ):
1399
+ # duplicate settings from the prev model on new model creation
1400
+ if json_dict is None and len(self.pydantic_classes) > 0:
1401
+ ret = self.build_model_and_run(
1402
+ validate_return_prev_model_json_txt=True
1403
+ )
1404
+ if ret is None:
1405
+ return
1406
+ key, json_txt = ret
1407
+ self.prev_model_settings[key] = json.loads(json_txt)
1408
+ if json_dict is None:
1409
+ key = "-".join(selected_modes)
1410
+ if key in self.prev_model_settings.keys():
1411
+ json_dict = self.prev_model_settings[key]
1412
+
1413
+ # initialize the top container and specify what pydantic class to map from
1414
+ if my_loaded_model is not None:
1415
+ pydantic_class = my_loaded_model
1416
+ else:
1417
+ pydantic_class, ret_msg = self.build_model(selected_modes)
1418
+ if pydantic_class is None:
1419
+ self.message_box(ret_msg)
1420
+ return
1421
+
1422
+ # Final constant UI val and identifier
1423
+ _idx: Final[int] = self.index
1424
+ _str: Final[str] = str(uuid.uuid4())
1425
+
1426
+ # Container holding the pydantic UI components
1427
+ # Multiple instances/copies since more than 1 might be created
1428
+ recon_pydantic_container = widgets.Container(
1429
+ name=_str, scrollable=False
1430
+ )
1431
+
1432
+ self.add_pydantic_to_container(
1433
+ pydantic_class, recon_pydantic_container, exclude_modes, json_dict
1434
+ )
1435
+
1436
+ # Run a validation check to see if the selected options are permitted
1437
+ # before we create the GUI
1438
+ # get the kwargs from the container/class
1439
+ pydantic_kwargs = {}
1440
+ pydantic_kwargs, ret_msg = self.get_and_validate_pydantic_args(
1441
+ recon_pydantic_container,
1442
+ pydantic_class,
1443
+ pydantic_kwargs,
1444
+ exclude_modes,
1445
+ )
1446
+ if pydantic_kwargs is None:
1447
+ self.message_box(ret_msg)
1448
+ return
1449
+
1450
+ # For list element, this needs to be cleaned and parsed back as an array
1451
+ input_channel_names, ret_msg = self.clean_string_for_list(
1452
+ "input_channel_names", pydantic_kwargs["input_channel_names"]
1453
+ )
1454
+ if input_channel_names is None:
1455
+ self.message_box(ret_msg)
1456
+ return
1457
+ pydantic_kwargs["input_channel_names"] = input_channel_names
1458
+
1459
+ time_indices, ret_msg = self.clean_string_int_for_list(
1460
+ "time_indices", pydantic_kwargs["time_indices"]
1461
+ )
1462
+ if time_indices is None:
1463
+ self.message_box(ret_msg)
1464
+ return
1465
+ pydantic_kwargs["time_indices"] = time_indices
1466
+
1467
+ if "birefringence" in pydantic_kwargs.keys():
1468
+ background_path, ret_msg = self.clean_path_string_when_empty(
1469
+ "background_path",
1470
+ pydantic_kwargs["birefringence"]["apply_inverse"][
1471
+ "background_path"
1472
+ ],
1473
+ )
1474
+ if background_path is None:
1475
+ self.message_box(ret_msg)
1476
+ return
1477
+ pydantic_kwargs["birefringence"]["apply_inverse"][
1478
+ "background_path"
1479
+ ] = background_path
1480
+
1481
+ # validate and return errors if None
1482
+ pydantic_model, ret_msg = self.validate_pydantic_model(
1483
+ pydantic_class, pydantic_kwargs
1484
+ )
1485
+ if pydantic_model is None:
1486
+ self.message_box(ret_msg)
1487
+ return
1488
+
1489
+ # generate a json from the instantiated model, update the json_display
1490
+ # most of this will end up in a table as processing proceeds
1491
+ json_txt, ret_msg = self.validate_and_return_json(pydantic_model)
1492
+ if json_txt is None:
1493
+ self.message_box(ret_msg)
1494
+ return
1495
+
1496
+ # PushButton to delete a UI container
1497
+ # Use case when a wrong selection of input modes get selected eg Bire+Fl
1498
+ # Preferably this root level validation should occur before values arevalidated
1499
+ # in order to display and avoid this to occur
1500
+ _del_button = widgets.PushButton(name="Delete Model")
1501
+
1502
+ c_mode = "-and-".join(selected_modes)
1503
+ c_mode_short = "".join(
1504
+ item[:3].capitalize() for item in selected_modes
1505
+ )
1506
+ if c_mode in CONTAINERS_INFO.keys():
1507
+ CONTAINERS_INFO[c_mode] += 1
1508
+ else:
1509
+ CONTAINERS_INFO[c_mode] = 1
1510
+ num_str = "{:02d}".format(CONTAINERS_INFO[c_mode])
1511
+ c_mode_str = f"{c_mode} - {num_str}"
1512
+
1513
+ # Output Data location
1514
+ # These could be multiple based on user selection for each model
1515
+ # Inherits from Input by default at creation time
1516
+ name_without_ext = os.path.splitext(Path(self.input_directory).name)[0]
1517
+ save_path = os.path.join(
1518
+ Path(self.output_directory).absolute(),
1519
+ (
1520
+ name_without_ext
1521
+ + ("_" + c_mode_short + "_" + num_str)
1522
+ + ".zarr"
1523
+ ),
1524
+ )
1525
+ save_path_exists = True if Path(save_path).exists() else False
1526
+ _output_data_loc = widgets.LineEdit(
1527
+ value=Path(save_path).name,
1528
+ tooltip=(
1529
+ ""
1530
+ if not save_path_exists
1531
+ else (_validate_alert + " Output file exists")
1532
+ ),
1533
+ )
1534
+ _output_data_btn = widgets.PushButton(
1535
+ text=("" if not save_path_exists else (_validate_alert + " "))
1536
+ + "Output Data:",
1537
+ tooltip=(
1538
+ ""
1539
+ if not save_path_exists
1540
+ else (_validate_alert + " Output file exists")
1541
+ ),
1542
+ )
1543
+
1544
+ # Passing location label to output location selector
1545
+ _output_data_btn.clicked.connect(
1546
+ lambda: self.browse_model_dir_path_output(_output_data_loc)
1547
+ )
1548
+ _output_data_loc.changed.connect(
1549
+ lambda: self.read_and_set_output_path_on_validation(
1550
+ _output_data_loc, _output_data_btn, save_path
1551
+ )
1552
+ )
1553
+
1554
+ _show_CheckBox = widgets.CheckBox(
1555
+ name="Show after Reconstruction", value=True
1556
+ )
1557
+ _show_CheckBox.max_width = 200
1558
+ _validate_button = widgets.PushButton(name="Validate")
1559
+
1560
+ # Passing all UI components that would be deleted
1561
+ _expandingTabEntryWidget = QWidget()
1562
+ _del_button.clicked.connect(
1563
+ lambda: self.delete_model(
1564
+ _expandingTabEntryWidget,
1565
+ recon_pydantic_container.native,
1566
+ _output_data_loc.native,
1567
+ _output_data_btn.native,
1568
+ _show_CheckBox.native,
1569
+ _validate_button.native,
1570
+ _del_button.native,
1571
+ _str,
1572
+ )
1573
+ )
1574
+
1575
+ # HBox for Output Data
1576
+ _hBox_widget = QWidget()
1577
+ _hBox_layout = QHBoxLayout()
1578
+ _hBox_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop)
1579
+ _hBox_widget.setLayout(_hBox_layout)
1580
+ _hBox_layout.addWidget(_output_data_btn.native)
1581
+ _hBox_layout.addWidget(_output_data_loc.native)
1582
+
1583
+ # Add this container to the main scrollable widget
1584
+ _scrollAreaCollapsibleBoxWidgetLayout = QVBoxLayout()
1585
+ _scrollAreaCollapsibleBoxWidgetLayout.setAlignment(
1586
+ QtCore.Qt.AlignmentFlag.AlignTop
1587
+ )
1588
+
1589
+ _scrollAreaCollapsibleBoxWidget = MyWidget()
1590
+ _scrollAreaCollapsibleBoxWidget.setLayout(
1591
+ _scrollAreaCollapsibleBoxWidgetLayout
1592
+ )
1593
+
1594
+ _scrollAreaCollapsibleBox = QScrollArea()
1595
+ _scrollAreaCollapsibleBox.setWidgetResizable(True)
1596
+ _scrollAreaCollapsibleBox.setWidget(_scrollAreaCollapsibleBoxWidget)
1597
+
1598
+ _collapsibleBoxWidgetLayout = QVBoxLayout()
1599
+ _collapsibleBoxWidgetLayout.setAlignment(Qt.AlignmentFlag.AlignTop)
1600
+
1601
+ scrollbar = _scrollAreaCollapsibleBox.horizontalScrollBar()
1602
+ _scrollAreaCollapsibleBoxWidget.resized.connect(
1603
+ lambda: self.check_scrollbar_visibility(scrollbar)
1604
+ )
1605
+
1606
+ _scrollAreaCollapsibleBoxWidgetLayout.addWidget(
1607
+ scrollbar, alignment=Qt.AlignmentFlag.AlignTop
1608
+ ) # Place at the top
1609
+
1610
+ _collapsibleBoxWidgetLayout.addWidget(_scrollAreaCollapsibleBox)
1611
+
1612
+ _collapsibleBoxWidget = CollapsibleBox(
1613
+ title=c_mode_str, expanded=True if _idx == 0 else False
1614
+ ) # tableEntryID, tableEntryShortDesc - should update with processing status
1615
+
1616
+ _validate_button.clicked.connect(
1617
+ lambda: self.validate_model(_str, _collapsibleBoxWidget)
1618
+ )
1619
+
1620
+ _hBox_widget2 = QWidget()
1621
+ _hBox_layout2 = QHBoxLayout()
1622
+ _hBox_layout2.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop)
1623
+ _hBox_widget2.setLayout(_hBox_layout2)
1624
+ _hBox_layout2.addWidget(_show_CheckBox.native)
1625
+ _hBox_layout2.addWidget(_validate_button.native)
1626
+ _hBox_layout2.addWidget(_del_button.native)
1627
+
1628
+ _expandingTabEntryWidgetLayout = QVBoxLayout()
1629
+ _expandingTabEntryWidgetLayout.setAlignment(
1630
+ QtCore.Qt.AlignmentFlag.AlignTop
1631
+ )
1632
+ _expandingTabEntryWidgetLayout.addWidget(_collapsibleBoxWidget)
1633
+
1634
+ _expandingTabEntryWidget.toolTip = c_mode_str
1635
+ _expandingTabEntryWidget.setLayout(_expandingTabEntryWidgetLayout)
1636
+ _expandingTabEntryWidget.setSizePolicy(
1637
+ QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
1638
+ )
1639
+ _expandingTabEntryWidget.layout().setAlignment(
1640
+ QtCore.Qt.AlignmentFlag.AlignTop
1641
+ )
1642
+
1643
+ _scrollAreaCollapsibleBoxWidgetLayout.addWidget(
1644
+ recon_pydantic_container.native
1645
+ )
1646
+ _scrollAreaCollapsibleBoxWidgetLayout.addWidget(_hBox_widget)
1647
+ _scrollAreaCollapsibleBoxWidgetLayout.addWidget(_hBox_widget2)
1648
+
1649
+ _scrollAreaCollapsibleBox.setMinimumHeight(
1650
+ _scrollAreaCollapsibleBoxWidgetLayout.sizeHint().height() + 20
1651
+ )
1652
+ _collapsibleBoxWidget.setSizePolicy(
1653
+ QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
1654
+ )
1655
+ _collapsibleBoxWidget.setContentLayout(_collapsibleBoxWidgetLayout)
1656
+
1657
+ self.models_container_widget_layout.addWidget(_expandingTabEntryWidget)
1658
+
1659
+ # Store a copy of the pydantic container along with all its associated components and properties
1660
+ # We dont needs a copy of the class but storing for now
1661
+ # This will be used for making deletion edits and looping to create our final run output
1662
+ # uuid - used for identiying in editable list
1663
+ self.pydantic_classes.append(
1664
+ {
1665
+ "uuid": _str,
1666
+ "c_mode_str": c_mode_str,
1667
+ "collapsibleBoxWidget": _collapsibleBoxWidget,
1668
+ "class": pydantic_class,
1669
+ "input": self.data_input_LineEdit,
1670
+ "output": os.path.join(
1671
+ Path(self.output_directory).absolute(),
1672
+ _output_data_loc.value,
1673
+ ),
1674
+ "output_parent_dir": str(
1675
+ Path(self.output_directory).absolute()
1676
+ ),
1677
+ "output_LineEdit": _output_data_loc,
1678
+ "output_Button": _output_data_btn,
1679
+ "container": recon_pydantic_container,
1680
+ "selected_modes": selected_modes.copy(),
1681
+ "exclude_modes": exclude_modes.copy(),
1682
+ "poll_data": self.pollData,
1683
+ "show": _show_CheckBox,
1684
+ }
1685
+ )
1686
+ self.index += 1
1687
+
1688
+ if self.index > 1:
1689
+ self.reconstruction_run_PushButton.text = "RUN {n} Models".format(
1690
+ n=self.index
1691
+ )
1692
+ else:
1693
+ self.reconstruction_run_PushButton.text = "RUN Model"
1694
+
1695
+ return pydantic_model
1696
+
1697
+ def check_scrollbar_visibility(self, scrollbar):
1698
+ h_scrollbar = scrollbar
1699
+
1700
+ # Hide scrollbar if not needed
1701
+ h_scrollbar.setVisible(h_scrollbar.maximum() > h_scrollbar.minimum())
1702
+
1703
+ def validate_model(self, _str, _collapsibleBoxWidget):
1704
+ i = 0
1705
+ model_entry_item = None
1706
+ for item in self.pydantic_classes:
1707
+ if item["uuid"] == _str:
1708
+ model_entry_item = item
1709
+ break
1710
+ i += 1
1711
+ if model_entry_item is not None:
1712
+ cls = item["class"]
1713
+ cls_container = item["container"]
1714
+ exclude_modes = item["exclude_modes"]
1715
+ c_mode_str = item["c_mode_str"]
1716
+
1717
+ # build up the arguments for the pydantic model given the current container
1718
+ if cls is None:
1719
+ self.message_box("No model defined !")
1720
+ return
1721
+
1722
+ pydantic_kwargs = {}
1723
+ pydantic_kwargs, ret_msg = self.get_and_validate_pydantic_args(
1724
+ cls_container, cls, pydantic_kwargs, exclude_modes
1725
+ )
1726
+ if pydantic_kwargs is None:
1727
+ self.message_box(ret_msg)
1728
+ _collapsibleBoxWidget.setNewName(
1729
+ f"{c_mode_str} {_validate_alert}"
1730
+ )
1731
+ return
1732
+
1733
+ input_channel_names, ret_msg = self.clean_string_for_list(
1734
+ "input_channel_names", pydantic_kwargs["input_channel_names"]
1735
+ )
1736
+ if input_channel_names is None:
1737
+ self.message_box(ret_msg)
1738
+ _collapsibleBoxWidget.setNewName(
1739
+ f"{c_mode_str} {_validate_alert}"
1740
+ )
1741
+ return
1742
+ pydantic_kwargs["input_channel_names"] = input_channel_names
1743
+
1744
+ time_indices, ret_msg = self.clean_string_int_for_list(
1745
+ "time_indices", pydantic_kwargs["time_indices"]
1746
+ )
1747
+ if time_indices is None:
1748
+ self.message_box(ret_msg)
1749
+ _collapsibleBoxWidget.setNewName(
1750
+ f"{c_mode_str} {_validate_alert}"
1751
+ )
1752
+ return
1753
+ pydantic_kwargs["time_indices"] = time_indices
1754
+
1755
+ time_indices, ret_msg = self.clean_string_int_for_list(
1756
+ "time_indices", pydantic_kwargs["time_indices"]
1757
+ )
1758
+ if time_indices is None:
1759
+ self.message_box(ret_msg)
1760
+ _collapsibleBoxWidget.setNewName(
1761
+ f"{c_mode_str} {_validate_alert}"
1762
+ )
1763
+ return
1764
+ pydantic_kwargs["time_indices"] = time_indices
1765
+
1766
+ if "birefringence" in pydantic_kwargs.keys():
1767
+ background_path, ret_msg = self.clean_path_string_when_empty(
1768
+ "background_path",
1769
+ pydantic_kwargs["birefringence"]["apply_inverse"][
1770
+ "background_path"
1771
+ ],
1772
+ )
1773
+ if background_path is None:
1774
+ self.message_box(ret_msg)
1775
+ _collapsibleBoxWidget.setNewName(
1776
+ f"{c_mode_str} {_validate_alert}"
1777
+ )
1778
+ return
1779
+ pydantic_kwargs["birefringence"]["apply_inverse"][
1780
+ "background_path"
1781
+ ] = background_path
1782
+
1783
+ # validate and return errors if None
1784
+ pydantic_model, ret_msg = self.validate_pydantic_model(
1785
+ cls, pydantic_kwargs
1786
+ )
1787
+ if pydantic_model is None:
1788
+ self.message_box(ret_msg)
1789
+ _collapsibleBoxWidget.setNewName(
1790
+ f"{c_mode_str} {_validate_alert}"
1791
+ )
1792
+ return
1793
+ if ret_msg == MSG_SUCCESS:
1794
+ _collapsibleBoxWidget.setNewName(
1795
+ f"{c_mode_str} {_validate_ok}"
1796
+ )
1797
+ else:
1798
+ _collapsibleBoxWidget.setNewName(
1799
+ f"{c_mode_str} {_validate_alert}"
1800
+ )
1801
+
1802
+ # UI components deletion - maybe just needs the parent container instead of individual components
1803
+ def delete_model(self, wid0, wid1, wid2, wid3, wid4, wid5, wid6, _str):
1804
+
1805
+ if not self.confirm_dialog():
1806
+ return False
1807
+
1808
+ if wid0 is not None:
1809
+ wid0.setParent(None)
1810
+
1811
+ # Find and remove the class from our pydantic model list using uuid
1812
+ i = 0
1813
+ for item in self.pydantic_classes:
1814
+ if item["uuid"] == _str:
1815
+ self.pydantic_classes.pop(i)
1816
+ break
1817
+ i += 1
1818
+ self.index = len(self.pydantic_classes)
1819
+ if self.index > 1:
1820
+ self.reconstruction_run_PushButton.text = "RUN {n} Models".format(
1821
+ n=self.index
1822
+ )
1823
+ else:
1824
+ self.reconstruction_run_PushButton.text = "RUN Model"
1825
+
1826
+ # Clear all the generated pydantic models and clears the pydantic model list
1827
+ def clear_all_models(self, silent=False):
1828
+
1829
+ if silent or self.confirm_dialog():
1830
+ index = self.models_container_widget_layout.count() - 1
1831
+ while index >= 0:
1832
+ myWidget = self.models_container_widget_layout.itemAt(
1833
+ index
1834
+ ).widget()
1835
+ if myWidget is not None:
1836
+ myWidget.setParent(None)
1837
+ index -= 1
1838
+ self.pydantic_classes.clear()
1839
+ CONTAINERS_INFO.clear()
1840
+ self.index = 0
1841
+ self.reconstruction_run_PushButton.text = "RUN Model"
1842
+ self.prev_model_settings = {}
1843
+
1844
+ # Displays the json output from the pydantic model UI selections by user
1845
+ # Loops through all our stored pydantic classes
1846
+ def build_model_and_run(self, validate_return_prev_model_json_txt=False):
1847
+ # we dont want to have a partial run if there are N models
1848
+ # so we will validate them all first and then run in a second loop
1849
+ # first pass for validating
1850
+ # second pass for creating yaml and processing
1851
+
1852
+ if len(self.pydantic_classes) == 0:
1853
+ self.message_box("Please create a processing model first !")
1854
+ return
1855
+
1856
+ self.model_reset_highlighter_setter() # reset the container elements that might be highlighted for errors
1857
+ _collectAllErrors = {}
1858
+ _collectAllErrorsBool = True
1859
+ for item in self.pydantic_classes:
1860
+ cls = item["class"]
1861
+ cls_container = item["container"]
1862
+ selected_modes = item["selected_modes"]
1863
+ exclude_modes = item["exclude_modes"]
1864
+ uuid_str = item["uuid"]
1865
+ _collapsibleBoxWidget = item["collapsibleBoxWidget"]
1866
+ c_mode_str = item["c_mode_str"]
1867
+
1868
+ _collectAllErrors[uuid_str] = {}
1869
+ _collectAllErrors[uuid_str]["cls"] = cls_container
1870
+ _collectAllErrors[uuid_str]["errs"] = []
1871
+ _collectAllErrors[uuid_str]["collapsibleBox"] = c_mode_str
1872
+
1873
+ # build up the arguments for the pydantic model given the current container
1874
+ if cls is None:
1875
+ self.message_box(ret_msg)
1876
+ return
1877
+
1878
+ # get the kwargs from the container/class
1879
+ pydantic_kwargs = {}
1880
+ pydantic_kwargs, ret_msg = self.get_and_validate_pydantic_args(
1881
+ cls_container, cls, pydantic_kwargs, exclude_modes
1882
+ )
1883
+ if pydantic_kwargs is None and not _collectAllErrorsBool:
1884
+ self.message_box(ret_msg)
1885
+ return
1886
+
1887
+ # For list element, this needs to be cleaned and parsed back as an array
1888
+ input_channel_names, ret_msg = self.clean_string_for_list(
1889
+ "input_channel_names", pydantic_kwargs["input_channel_names"]
1890
+ )
1891
+ if input_channel_names is None and not _collectAllErrorsBool:
1892
+ self.message_box(ret_msg)
1893
+ return
1894
+ pydantic_kwargs["input_channel_names"] = input_channel_names
1895
+
1896
+ time_indices, ret_msg = self.clean_string_int_for_list(
1897
+ "time_indices", pydantic_kwargs["time_indices"]
1898
+ )
1899
+ if time_indices is None and not _collectAllErrorsBool:
1900
+ self.message_box(ret_msg)
1901
+ return
1902
+ pydantic_kwargs["time_indices"] = time_indices
1903
+
1904
+ if "birefringence" in pydantic_kwargs.keys():
1905
+ background_path, ret_msg = self.clean_path_string_when_empty(
1906
+ "background_path",
1907
+ pydantic_kwargs["birefringence"]["apply_inverse"][
1908
+ "background_path"
1909
+ ],
1910
+ )
1911
+ if background_path is None and not _collectAllErrorsBool:
1912
+ self.message_box(ret_msg)
1913
+ return
1914
+ pydantic_kwargs["birefringence"]["apply_inverse"][
1915
+ "background_path"
1916
+ ] = background_path
1917
+
1918
+ # validate and return errors if None
1919
+ pydantic_model, ret_msg = self.validate_pydantic_model(
1920
+ cls, pydantic_kwargs
1921
+ )
1922
+ if ret_msg == MSG_SUCCESS:
1923
+ _collapsibleBoxWidget.setNewName(
1924
+ f"{c_mode_str} {_validate_ok}"
1925
+ )
1926
+ else:
1927
+ _collapsibleBoxWidget.setNewName(
1928
+ f"{c_mode_str} {_validate_alert}"
1929
+ )
1930
+ _collectAllErrors[uuid_str]["errs"] = ret_msg
1931
+ if pydantic_model is None and not _collectAllErrorsBool:
1932
+ self.message_box(ret_msg)
1933
+ return
1934
+
1935
+ # generate a json from the instantiated model, update the json_display
1936
+ # most of this will end up in a table as processing proceeds
1937
+ json_txt, ret_msg = self.validate_and_return_json(pydantic_model)
1938
+ if json_txt is None and not _collectAllErrorsBool:
1939
+ self.message_box(ret_msg)
1940
+ return
1941
+
1942
+ # check if we collected any validation errors before continuing
1943
+ for uu_key in _collectAllErrors.keys():
1944
+ if len(_collectAllErrors[uu_key]["errs"]) > 0:
1945
+ self.model_highlighter(_collectAllErrors)
1946
+ fmt_str = self.format_string_for_error_display(
1947
+ _collectAllErrors
1948
+ )
1949
+ self.message_box(fmt_str)
1950
+ return
1951
+
1952
+ if validate_return_prev_model_json_txt:
1953
+ return "-".join(selected_modes), json_txt
1954
+
1955
+ # generate a time-stamp for our yaml files to avoid overwriting
1956
+ # files generated at the same time will have an index suffix
1957
+ now = datetime.datetime.now()
1958
+ ms = now.strftime("%f")[:3]
1959
+ unique_id = now.strftime("%Y_%m_%d_%H_%M_%S_") + ms
1960
+
1961
+ if self.pollData:
1962
+ data = open_ome_zarr(self.input_directory, mode="r")
1963
+ if "CurrentDimensions" in data.zattrs.keys():
1964
+ my_dict_time_indices = data.zattrs["CurrentDimensions"]["time"]
1965
+ # get the prev time_index, since this is current acq
1966
+ if my_dict_time_indices - 1 > 1:
1967
+ time_indices = list(range(0, my_dict_time_indices))
1968
+ else:
1969
+ time_indices = 0
1970
+
1971
+ pollDataThread = threading.Thread(
1972
+ target=self.add_poll_loop,
1973
+ args=(self.input_directory, my_dict_time_indices - 1),
1974
+ )
1975
+ pollDataThread.start()
1976
+
1977
+ i = 0
1978
+ for item in self.pydantic_classes:
1979
+ i += 1
1980
+ cls = item["class"]
1981
+ cls_container = item["container"]
1982
+ selected_modes = item["selected_modes"]
1983
+ exclude_modes = item["exclude_modes"]
1984
+ c_mode_str = item["c_mode_str"]
1985
+ output_LineEdit = item["output_LineEdit"]
1986
+ output_parent_dir = item["output_parent_dir"]
1987
+
1988
+ full_out_path = os.path.join(
1989
+ output_parent_dir, output_LineEdit.value
1990
+ )
1991
+
1992
+ # gather input/out locations
1993
+ input_dir = f"{item['input'].value}"
1994
+ output_dir = full_out_path
1995
+
1996
+ # build up the arguments for the pydantic model given the current container
1997
+ if cls is None:
1998
+ self.message_box("No model defined !")
1999
+ return
2000
+
2001
+ pydantic_kwargs = {}
2002
+ pydantic_kwargs, ret_msg = self.get_and_validate_pydantic_args(
2003
+ cls_container, cls, pydantic_kwargs, exclude_modes
2004
+ )
2005
+ if pydantic_kwargs is None:
2006
+ self.message_box(ret_msg)
2007
+ return
2008
+
2009
+ input_channel_names, ret_msg = self.clean_string_for_list(
2010
+ "input_channel_names", pydantic_kwargs["input_channel_names"]
2011
+ )
2012
+ if input_channel_names is None:
2013
+ self.message_box(ret_msg)
2014
+ return
2015
+ pydantic_kwargs["input_channel_names"] = input_channel_names
2016
+
2017
+ if not self.pollData:
2018
+ time_indices, ret_msg = self.clean_string_int_for_list(
2019
+ "time_indices", pydantic_kwargs["time_indices"]
2020
+ )
2021
+ if time_indices is None:
2022
+ self.message_box(ret_msg)
2023
+ return
2024
+ pydantic_kwargs["time_indices"] = time_indices
2025
+
2026
+ time_indices, ret_msg = self.clean_string_int_for_list(
2027
+ "time_indices", pydantic_kwargs["time_indices"]
2028
+ )
2029
+ if time_indices is None:
2030
+ self.message_box(ret_msg)
2031
+ return
2032
+ pydantic_kwargs["time_indices"] = time_indices
2033
+
2034
+ if "birefringence" in pydantic_kwargs.keys():
2035
+ background_path, ret_msg = self.clean_path_string_when_empty(
2036
+ "background_path",
2037
+ pydantic_kwargs["birefringence"]["apply_inverse"][
2038
+ "background_path"
2039
+ ],
2040
+ )
2041
+ if background_path is None:
2042
+ self.message_box(ret_msg)
2043
+ return
2044
+ pydantic_kwargs["birefringence"]["apply_inverse"][
2045
+ "background_path"
2046
+ ] = background_path
2047
+
2048
+ # validate and return errors if None
2049
+ pydantic_model, ret_msg = self.validate_pydantic_model(
2050
+ cls, pydantic_kwargs
2051
+ )
2052
+ if pydantic_model is None:
2053
+ self.message_box(ret_msg)
2054
+ return
2055
+
2056
+ # generate a json from the instantiated model, update the json_display
2057
+ # most of this will end up in a table as processing proceeds
2058
+ json_txt, ret_msg = self.validate_and_return_json(pydantic_model)
2059
+ if json_txt is None:
2060
+ self.message_box(ret_msg)
2061
+ return
2062
+
2063
+ # save the yaml files
2064
+ # path is next to saved data location
2065
+ save_config_path = str(Path(output_dir).parent.absolute())
2066
+ yml_file_name = "-and-".join(selected_modes)
2067
+ yml_file = (
2068
+ yml_file_name + "-" + unique_id + "-{:02d}".format(i) + ".yml"
2069
+ )
2070
+ config_path = os.path.join(save_config_path, yml_file)
2071
+ utils.model_to_yaml(pydantic_model, config_path)
2072
+
2073
+ # Input params for table entry
2074
+ # Once ALL entries are entered we can deleted ALL model containers
2075
+ # Table will need a low priority update thread to refresh status queried from CLI
2076
+ # Table entries will be purged on completion when Result is returned OK
2077
+ # Table entries will show an error msg when processing finishes but Result not OK
2078
+ # Table fields ID / DateTime, Reconstruction type, Input Location, Output Location, Progress indicator, Stop button
2079
+
2080
+ expID = "{tID}-{idx}".format(tID=unique_id, idx=i)
2081
+ tableID = "{tName}: ({tID}-{idx})".format(
2082
+ tName=c_mode_str, tID=unique_id, idx=i
2083
+ )
2084
+ tableDescToolTip = "{tName}: ({tID}-{idx})".format(
2085
+ tName=yml_file_name, tID=unique_id, idx=i
2086
+ )
2087
+
2088
+ proc_params = {}
2089
+ proc_params["exp_id"] = expID
2090
+ proc_params["desc"] = tableDescToolTip
2091
+ proc_params["config_path"] = str(Path(config_path).absolute())
2092
+ proc_params["input_path"] = str(Path(input_dir).absolute())
2093
+ proc_params["output_path"] = str(Path(output_dir).absolute())
2094
+ proc_params["output_path_parent"] = str(
2095
+ Path(output_dir).parent.absolute()
2096
+ )
2097
+ proc_params["show"] = item["show"].value
2098
+
2099
+ self.addTableEntry(tableID, tableDescToolTip, proc_params)
2100
+
2101
+ def add_poll_loop(self, input_data_path, last_time_index):
2102
+ _pydantic_classes = self.pydantic_classes.copy()
2103
+ required_order = ["time", "position", "z", "channel"]
2104
+ _pollData = True
2105
+
2106
+ tableEntryWorker = AddOTFTableEntryWorkerThread(
2107
+ input_data_path, True, False
2108
+ )
2109
+ tableEntryWorker.add_tableOTFentry_signal.connect(
2110
+ self.add_remove_check_OTF_table_entry
2111
+ )
2112
+ tableEntryWorker.start()
2113
+ _breakFlag = False
2114
+ while True:
2115
+ time.sleep(10)
2116
+ zattrs_data = None
2117
+ try:
2118
+ _stopCalled = self.add_remove_check_OTF_table_entry(
2119
+ input_data_path, True, do_check=True
2120
+ )
2121
+ if _stopCalled:
2122
+ tableEntryWorker2 = AddOTFTableEntryWorkerThread(
2123
+ input_data_path, False, False
2124
+ )
2125
+ tableEntryWorker2.add_tableOTFentry_signal.connect(
2126
+ self.add_remove_check_OTF_table_entry
2127
+ )
2128
+ tableEntryWorker2.start()
2129
+
2130
+ # let child threads finish their work before exiting the parent thread
2131
+ while tableEntryWorker2.isRunning():
2132
+ time.sleep(1)
2133
+ time.sleep(5)
2134
+ break
2135
+ try:
2136
+ data = open_ome_zarr(input_data_path, mode="r")
2137
+ zattrs_data = data.zattrs
2138
+ except PermissionError:
2139
+ pass # On-The-Fly dataset will throw Permission Denied when being written
2140
+ # Maybe we can read the zaatrs directly in that case
2141
+ # If this write/read is a constant issue then the zattrs 'CurrentDimensions' key
2142
+ # should be updated less frequently, instead of current design of updating with
2143
+ # each image
2144
+
2145
+ if zattrs_data is None:
2146
+ zattrs_data = self.load_zattrs_directly_as_dict(
2147
+ input_data_path
2148
+ )
2149
+
2150
+ if zattrs_data is not None:
2151
+ if "CurrentDimensions" in zattrs_data.keys():
2152
+ my_dict1 = zattrs_data["CurrentDimensions"]
2153
+ sorted_dict_acq = {
2154
+ k: my_dict1[k]
2155
+ for k in sorted(
2156
+ my_dict1, key=lambda x: required_order.index(x)
2157
+ )
2158
+ }
2159
+ my_dict_time_indices_curr = zattrs_data[
2160
+ "CurrentDimensions"
2161
+ ]["time"]
2162
+ # print(sorted_dict_acq)
2163
+
2164
+ if "FinalDimensions" in zattrs_data.keys():
2165
+ my_dict2 = zattrs_data["FinalDimensions"]
2166
+ sorted_dict_final = {
2167
+ k: my_dict2[k]
2168
+ for k in sorted(
2169
+ my_dict2, key=lambda x: required_order.index(x)
2170
+ )
2171
+ }
2172
+ # print(sorted_dict_final)
2173
+
2174
+ # use the prev time_index, since this is current acq and we need for other dims to finish acq for this t
2175
+ # or when all dims match - signifying acq finished
2176
+ if (
2177
+ my_dict_time_indices_curr - 2 > last_time_index
2178
+ or json.dumps(sorted_dict_acq)
2179
+ == json.dumps(sorted_dict_final)
2180
+ ):
2181
+
2182
+ now = datetime.datetime.now()
2183
+ ms = now.strftime("%f")[:3]
2184
+ unique_id = now.strftime("%Y_%m_%d_%H_%M_%S_") + ms
2185
+
2186
+ i = 0
2187
+ for item in _pydantic_classes:
2188
+ i += 1
2189
+ cls = item["class"]
2190
+ cls_container = item["container"]
2191
+ selected_modes = item["selected_modes"]
2192
+ exclude_modes = item["exclude_modes"]
2193
+ c_mode_str = item["c_mode_str"]
2194
+ output_LineEdit = item["output_LineEdit"]
2195
+ output_parent_dir = item["output_parent_dir"]
2196
+
2197
+ full_out_path = os.path.join(
2198
+ output_parent_dir, output_LineEdit.value
2199
+ )
2200
+ # gather input/out locations
2201
+ input_dir = f"{item['input'].value}"
2202
+ output_dir = full_out_path
2203
+
2204
+ pydantic_kwargs = {}
2205
+ pydantic_kwargs, ret_msg = (
2206
+ self.get_and_validate_pydantic_args(
2207
+ cls_container,
2208
+ cls,
2209
+ pydantic_kwargs,
2210
+ exclude_modes,
2211
+ )
2212
+ )
2213
+
2214
+ input_channel_names, ret_msg = (
2215
+ self.clean_string_for_list(
2216
+ "input_channel_names",
2217
+ pydantic_kwargs["input_channel_names"],
2218
+ )
2219
+ )
2220
+ pydantic_kwargs["input_channel_names"] = (
2221
+ input_channel_names
2222
+ )
2223
+
2224
+ if _pollData:
2225
+ if json.dumps(sorted_dict_acq) == json.dumps(
2226
+ sorted_dict_final
2227
+ ):
2228
+ time_indices = list(
2229
+ range(
2230
+ last_time_index,
2231
+ my_dict_time_indices_curr,
2232
+ )
2233
+ )
2234
+ _breakFlag = True
2235
+ else:
2236
+ time_indices = list(
2237
+ range(
2238
+ last_time_index,
2239
+ my_dict_time_indices_curr - 2,
2240
+ )
2241
+ )
2242
+ pydantic_kwargs["time_indices"] = time_indices
2243
+
2244
+ if "birefringence" in pydantic_kwargs.keys():
2245
+ background_path, ret_msg = (
2246
+ self.clean_path_string_when_empty(
2247
+ "background_path",
2248
+ pydantic_kwargs["birefringence"][
2249
+ "apply_inverse"
2250
+ ]["background_path"],
2251
+ )
2252
+ )
2253
+
2254
+ pydantic_kwargs["birefringence"][
2255
+ "apply_inverse"
2256
+ ]["background_path"] = background_path
2257
+
2258
+ # validate and return errors if None
2259
+ pydantic_model, ret_msg = (
2260
+ self.validate_pydantic_model(
2261
+ cls, pydantic_kwargs
2262
+ )
2263
+ )
2264
+
2265
+ # save the yaml files
2266
+ # path is next to saved data location
2267
+ save_config_path = str(
2268
+ Path(output_dir).parent.absolute()
2269
+ )
2270
+ yml_file_name = "-and-".join(selected_modes)
2271
+ yml_file = (
2272
+ yml_file_name
2273
+ + "-"
2274
+ + unique_id
2275
+ + "-{:02d}".format(i)
2276
+ + ".yml"
2277
+ )
2278
+ config_path = os.path.join(
2279
+ save_config_path, yml_file
2280
+ )
2281
+ utils.model_to_yaml(pydantic_model, config_path)
2282
+
2283
+ expID = "{tID}-{idx}".format(tID=unique_id, idx=i)
2284
+ tableID = "{tName}: ({tID}-{idx})".format(
2285
+ tName=c_mode_str, tID=unique_id, idx=i
2286
+ )
2287
+ tableDescToolTip = "{tName}: ({tID}-{idx})".format(
2288
+ tName=yml_file_name, tID=unique_id, idx=i
2289
+ )
2290
+
2291
+ proc_params = {}
2292
+ proc_params["exp_id"] = expID
2293
+ proc_params["desc"] = tableDescToolTip
2294
+ proc_params["config_path"] = str(
2295
+ Path(config_path).absolute()
2296
+ )
2297
+ proc_params["input_path"] = str(
2298
+ Path(input_dir).absolute()
2299
+ )
2300
+ proc_params["output_path"] = str(
2301
+ Path(output_dir).absolute()
2302
+ )
2303
+ proc_params["output_path_parent"] = str(
2304
+ Path(output_dir).parent.absolute()
2305
+ )
2306
+ proc_params["show"] = False
2307
+
2308
+ tableEntryWorker1 = AddTableEntryWorkerThread(
2309
+ tableID, tableDescToolTip, proc_params
2310
+ )
2311
+ tableEntryWorker1.add_tableentry_signal.connect(
2312
+ self.addTableEntry
2313
+ )
2314
+ tableEntryWorker1.start()
2315
+
2316
+ if (
2317
+ json.dumps(sorted_dict_acq)
2318
+ == json.dumps(sorted_dict_final)
2319
+ and _breakFlag
2320
+ ):
2321
+
2322
+ tableEntryWorker2 = AddOTFTableEntryWorkerThread(
2323
+ input_data_path, False, False
2324
+ )
2325
+ tableEntryWorker2.add_tableOTFentry_signal.connect(
2326
+ self.add_remove_check_OTF_table_entry
2327
+ )
2328
+ tableEntryWorker2.start()
2329
+
2330
+ # let child threads finish their work before exiting the parent thread
2331
+ while (
2332
+ tableEntryWorker1.isRunning()
2333
+ or tableEntryWorker2.isRunning()
2334
+ ):
2335
+ time.sleep(1)
2336
+ time.sleep(5)
2337
+ break
2338
+
2339
+ last_time_index = my_dict_time_indices_curr - 2
2340
+ except Exception as exc:
2341
+ print(exc.args)
2342
+ print(
2343
+ "Exiting polling for dataset: {data_path}".format(
2344
+ data_path=input_data_path
2345
+ )
2346
+ )
2347
+ break
2348
+
2349
+ def load_zattrs_directly_as_dict(self, zattrsFilePathDir):
2350
+ try:
2351
+ file_path = os.path.join(zattrsFilePathDir, ".zattrs")
2352
+ f = open(file_path, "r")
2353
+ txt = f.read()
2354
+ f.close()
2355
+ return json.loads(txt)
2356
+ except Exception as exc:
2357
+ print(exc.args)
2358
+ return None
2359
+
2360
+ # ======= These function do not implement validation
2361
+ # They simply make the data from GUI translate to input types
2362
+ # that the model expects: for eg. GUI txt field will output only str
2363
+ # when the model needs integers
2364
+
2365
+ # util function to parse list elements displayed as string
2366
+ def remove_chars(self, string, chars_to_remove):
2367
+ for char in chars_to_remove:
2368
+ string = string.replace(char, "")
2369
+ return string
2370
+
2371
+ # util function to parse list elements displayed as string
2372
+ def clean_string_for_list(self, field, string):
2373
+ chars_to_remove = ["[", "]", "'", '"', " "]
2374
+ if isinstance(string, str):
2375
+ string = self.remove_chars(string, chars_to_remove)
2376
+ if len(string) == 0:
2377
+ return None, {"msg": field + " is invalid"}
2378
+ if "," in string:
2379
+ string = string.split(",")
2380
+ return string, MSG_SUCCESS
2381
+ if isinstance(string, str):
2382
+ string = [string]
2383
+ return string, MSG_SUCCESS
2384
+ return string, MSG_SUCCESS
2385
+
2386
+ # util function to parse list elements displayed as string, int, int as list of strings, int range
2387
+ # [1,2,3], 4,5,6 , 5-95
2388
+ def clean_string_int_for_list(self, field, string):
2389
+ chars_to_remove = ["[", "]", "'", '"', " "]
2390
+ if Literal[string] == Literal["all"]:
2391
+ return string, MSG_SUCCESS
2392
+ if Literal[string] == Literal[""]:
2393
+ return string, MSG_SUCCESS
2394
+ if isinstance(string, str):
2395
+ string = self.remove_chars(string, chars_to_remove)
2396
+ if len(string) == 0:
2397
+ return None, {"msg": field + " is invalid"}
2398
+ if "-" in string:
2399
+ string = string.split("-")
2400
+ if len(string) == 2:
2401
+ try:
2402
+ x = int(string[0])
2403
+ if not isinstance(x, int):
2404
+ raise
2405
+ except Exception as exc:
2406
+ return None, {
2407
+ "msg": field + " first range element is not an integer"
2408
+ }
2409
+ try:
2410
+ y = int(string[1])
2411
+ if not isinstance(y, int):
2412
+ raise
2413
+ except Exception as exc:
2414
+ return None, {
2415
+ "msg": field
2416
+ + " second range element is not an integer"
2417
+ }
2418
+ if y > x:
2419
+ return list(range(x, y + 1)), MSG_SUCCESS
2420
+ else:
2421
+ return None, {
2422
+ "msg": field
2423
+ + " second integer cannot be smaller than first"
2424
+ }
2425
+ else:
2426
+ return None, {"msg": field + " is invalid"}
2427
+ if "," in string:
2428
+ string = string.split(",")
2429
+ return string, MSG_SUCCESS
2430
+ return string, MSG_SUCCESS
2431
+
2432
+ # util function to set path to empty - by default empty path has a "."
2433
+ def clean_path_string_when_empty(self, field, string):
2434
+ if isinstance(string, Path) and string == Path(""):
2435
+ string = ""
2436
+ return string, MSG_SUCCESS
2437
+ return string, MSG_SUCCESS
2438
+
2439
+ # get the pydantic_kwargs and catches any errors in doing so
2440
+ def get_and_validate_pydantic_args(
2441
+ self, cls_container, cls, pydantic_kwargs, exclude_modes
2442
+ ):
2443
+ try:
2444
+ try:
2445
+ self.get_pydantic_kwargs(
2446
+ cls_container, cls, pydantic_kwargs, exclude_modes
2447
+ )
2448
+ return pydantic_kwargs, MSG_SUCCESS
2449
+ except ValidationError as exc:
2450
+ return None, exc.errors()
2451
+ except Exception as exc:
2452
+ return None, exc.args
2453
+
2454
+ # validate the model and return errors for user actioning
2455
+ def validate_pydantic_model(self, cls, pydantic_kwargs):
2456
+ # instantiate the pydantic model form the kwargs we just pulled
2457
+ try:
2458
+ try:
2459
+ pydantic_model = (
2460
+ settings.ReconstructionSettings.model_validate(
2461
+ pydantic_kwargs
2462
+ )
2463
+ )
2464
+ return pydantic_model, MSG_SUCCESS
2465
+ except ValidationError as exc:
2466
+ return None, exc.errors()
2467
+ except Exception as exc:
2468
+ return None, exc.args
2469
+
2470
+ # test to make sure model converts to json which should ensure compatibility with yaml export
2471
+ def validate_and_return_json(self, pydantic_model):
2472
+ try:
2473
+ json_format = pydantic_model.model_dump_json(indent=4)
2474
+ return json_format, MSG_SUCCESS
2475
+ except Exception as exc:
2476
+ return None, exc.args
2477
+
2478
+ # gets a copy of the model from a yaml file
2479
+ # will get all fields (even those that are optional and not in yaml) and default values
2480
+ # model needs further parsing against yaml file for fields
2481
+ def get_model_from_file(self, model_file_path):
2482
+ pydantic_model = None
2483
+ try:
2484
+ try:
2485
+ pydantic_model = utils.yaml_to_model(
2486
+ model_file_path, settings.ReconstructionSettings
2487
+ )
2488
+ except ValidationError as exc:
2489
+ return pydantic_model, exc.errors()
2490
+ if pydantic_model is None:
2491
+ raise Exception("utils.yaml_to_model - returned a None model")
2492
+ return pydantic_model, MSG_SUCCESS
2493
+ except Exception as exc:
2494
+ return None, exc.args
2495
+
2496
+ # handles json with boolean properly and converts to lowercase string
2497
+ # as required
2498
+ def convert(self, obj):
2499
+ if isinstance(obj, bool):
2500
+ return str(obj).lower()
2501
+ if isinstance(obj, (list, tuple)):
2502
+ return [self.convert(item) for item in obj]
2503
+ if isinstance(obj, dict):
2504
+ return {
2505
+ self.convert(key): self.convert(value)
2506
+ for key, value in obj.items()
2507
+ }
2508
+ return obj
2509
+
2510
+ # Main function to add pydantic model to container
2511
+ # https://github.com/chrishavlin/miscellaneous_python/blob/main/src/pydantic_magicgui_roundtrip.py
2512
+ # Has limitation and can cause breakages for unhandled or incorrectly handled types
2513
+ # Cannot handle Union types/typing - for now being handled explicitly
2514
+ # Ignoring NoneType since those should be Optional but maybe needs displaying ??
2515
+ # ToDo: Needs revisitation, Union check
2516
+ # Displaying Union field "time_indices" as LineEdit component
2517
+ # excludes handles fields that are not supposed to show up from model_fields
2518
+ # json_dict adds ability to provide new set of default values at time of container creation
2519
+
2520
+ def add_pydantic_to_container(
2521
+ self,
2522
+ py_model: type[BaseModel] | BaseModel,
2523
+ container: widgets.Container,
2524
+ excludes=None,
2525
+ json_dict=None,
2526
+ ):
2527
+ # recursively traverse a pydantic model adding widgets to a container. When a nested
2528
+ # pydantic model is encountered, add a new nested container
2529
+ if excludes is None:
2530
+ excludes = []
2531
+
2532
+ # Access model_fields from the class, not the instance
2533
+ model_class = (
2534
+ py_model if isinstance(py_model, type) else type(py_model)
2535
+ )
2536
+ is_instance = not isinstance(py_model, type)
2537
+ for field, field_def in model_class.model_fields.items():
2538
+ if field_def is not None and field not in excludes:
2539
+ # Get actual instance value if py_model is an instance, otherwise use class default
2540
+ if is_instance:
2541
+ def_val = getattr(py_model, field)
2542
+ else:
2543
+ def_val = field_def.default
2544
+ if isinstance(def_val, PydanticUndefinedType):
2545
+ def_val = None
2546
+ ftype = field_def.annotation
2547
+
2548
+ # Build tooltip from field metadata
2549
+ tooltip_parts = []
2550
+ if field_def.description:
2551
+ tooltip_parts.append(field_def.description)
2552
+ if field_def.metadata:
2553
+ constraints = [str(m) for m in field_def.metadata]
2554
+ tooltip_parts.extend(constraints)
2555
+ toolTip = " | ".join(tooltip_parts)
2556
+ # Handle nested Pydantic models, including Optional[Model]
2557
+ if (
2558
+ is_subclass_of(ftype, BaseModel)
2559
+ or ftype in PYDANTIC_CLASSES_DEF
2560
+ ):
2561
+ json_val = None
2562
+ if json_dict is not None and field in json_dict:
2563
+ json_val = json_dict[field]
2564
+ # the field is a pydantic class, add a container for it and fill it
2565
+ new_widget_cls = widgets.Container
2566
+ new_widget = new_widget_cls(name=field)
2567
+ new_widget.tooltip = toolTip
2568
+ # Unwrap Optional[Model] to get Model before recursing
2569
+ unwrapped_ftype = unwrap_optional(ftype)
2570
+ self.add_pydantic_to_container(
2571
+ unwrapped_ftype, new_widget, excludes, json_val
2572
+ )
2573
+ elif isinstance(ftype, type(Union[NonNegativeInt, List, str])):
2574
+ if (
2575
+ field == "background_path"
2576
+ ): # field == "background_path":
2577
+ new_widget_cls, ops = get_widget_class(
2578
+ def_val,
2579
+ Annotated[Path, {"mode": "d"}],
2580
+ dict(name=field, value=def_val),
2581
+ )
2582
+ new_widget = new_widget_cls(**ops)
2583
+ toolTip = (
2584
+ "Select the folder containing background.zarr"
2585
+ )
2586
+ elif field == "time_indices": # field == "time_indices":
2587
+ new_widget_cls, ops = get_widget_class(
2588
+ def_val, str, dict(name=field, value=def_val)
2589
+ )
2590
+ new_widget = new_widget_cls(**ops)
2591
+ else: # other Union cases
2592
+ new_widget_cls, ops = get_widget_class(
2593
+ def_val, str, dict(name=field, value=def_val)
2594
+ )
2595
+ new_widget = new_widget_cls(**ops)
2596
+ new_widget.tooltip = toolTip
2597
+ if isinstance(new_widget, widgets.EmptyWidget):
2598
+ warnings.warn(
2599
+ message=f"magicgui could not identify a widget for {py_model}.{field}, which has type {ftype}"
2600
+ )
2601
+ elif isinstance(def_val, float) or (
2602
+ def_val is None
2603
+ and is_subclass_of(
2604
+ ftype, (int, float), require_optional=True
2605
+ )
2606
+ ):
2607
+ # Handle float fields, including Optional[float] with None value
2608
+
2609
+ # For Optional numeric types with None, use LineEdit instead of FloatSpinBox
2610
+ # This allows empty string to represent None
2611
+ # Note: if we entered via the is_subclass_of check, def_val is guaranteed None
2612
+ if def_val is None:
2613
+ new_widget_cls, ops = get_widget_class(
2614
+ None,
2615
+ str,
2616
+ dict(name=field, value=""),
2617
+ )
2618
+ new_widget = new_widget_cls(**ops)
2619
+ new_widget.tooltip = (
2620
+ toolTip + " (Optional - leave empty for None)"
2621
+ )
2622
+ else:
2623
+ # Regular float field
2624
+ def_step_size = 0.001
2625
+ if field == "regularization_strength":
2626
+ def_step_size = 0.00001
2627
+
2628
+ if def_val > -1 and def_val < 1:
2629
+ new_widget_cls, ops = get_widget_class(
2630
+ None,
2631
+ ftype,
2632
+ dict(
2633
+ name=field,
2634
+ value=def_val,
2635
+ step=float(def_step_size),
2636
+ ),
2637
+ )
2638
+ new_widget = new_widget_cls(**ops)
2639
+ new_widget.tooltip = toolTip
2640
+ else:
2641
+ new_widget_cls, ops = get_widget_class(
2642
+ None,
2643
+ ftype,
2644
+ dict(name=field, value=def_val),
2645
+ )
2646
+ new_widget = new_widget_cls(**ops)
2647
+ new_widget.tooltip = toolTip
2648
+ if isinstance(new_widget, widgets.EmptyWidget):
2649
+ warnings.warn(
2650
+ message=f"magicgui could not identify a widget for {py_model}.{field}, which has type {ftype}"
2651
+ )
2652
+ else:
2653
+ # parse the field, add appropriate widget
2654
+ new_widget_cls, ops = get_widget_class(
2655
+ None, ftype, dict(name=field, value=def_val)
2656
+ )
2657
+ new_widget = new_widget_cls(**ops)
2658
+ if isinstance(new_widget, widgets.EmptyWidget):
2659
+ warnings.warn(
2660
+ message=f"magicgui could not identify a widget for {py_model}.{field}, which has type {ftype}"
2661
+ )
2662
+ else:
2663
+ new_widget.tooltip = toolTip
2664
+ if json_dict is not None and (
2665
+ not isinstance(new_widget, widgets.Container)
2666
+ or (isinstance(new_widget, widgets.FileEdit))
2667
+ ):
2668
+ if field in json_dict.keys():
2669
+ if isinstance(new_widget, widgets.CheckBox):
2670
+ new_widget.value = (
2671
+ True if json_dict[field] == "true" else False
2672
+ )
2673
+ elif isinstance(new_widget, widgets.FileEdit):
2674
+ if len(json_dict[field]) > 0:
2675
+ extension = os.path.splitext(json_dict[field])[
2676
+ 1
2677
+ ]
2678
+ if len(extension) > 0:
2679
+ new_widget.value = Path(
2680
+ json_dict[field]
2681
+ ).parent.absolute() # CLI accepts BG folder not .zarr
2682
+ else:
2683
+ new_widget.value = Path(json_dict[field])
2684
+ else:
2685
+ new_widget.value = json_dict[field]
2686
+ container.append(new_widget)
2687
+
2688
+ # refer - add_pydantic_to_container() for comments
2689
+ def get_pydantic_kwargs(
2690
+ self,
2691
+ container: widgets.Container,
2692
+ pydantic_model,
2693
+ pydantic_kwargs: dict,
2694
+ excludes=None,
2695
+ json_dict=None,
2696
+ ):
2697
+ # given a container that was instantiated from a pydantic model, get the arguments
2698
+ # needed to instantiate that pydantic model from the container.
2699
+ # traverse model fields, pull out values from container
2700
+ if excludes is None:
2701
+ excludes = []
2702
+
2703
+ # Access model_fields from the class, not the instance
2704
+ model_class = (
2705
+ pydantic_model
2706
+ if isinstance(pydantic_model, type)
2707
+ else type(pydantic_model)
2708
+ )
2709
+
2710
+ for field, field_def in model_class.model_fields.items():
2711
+ if field in excludes:
2712
+ continue
2713
+
2714
+ ftype = field_def.annotation
2715
+ if is_subclass_of(ftype, BaseModel):
2716
+ # Nested Pydantic model - recurse
2717
+ pydantic_kwargs[field] = {}
2718
+ self.get_pydantic_kwargs(
2719
+ getattr(container, field),
2720
+ unwrap_optional(ftype),
2721
+ pydantic_kwargs[field],
2722
+ excludes,
2723
+ json_dict,
2724
+ )
2725
+ else:
2726
+ # Leaf field - extract value from container widget
2727
+ value = getattr(container, field).value
2728
+ # Handle Optional numeric types: convert empty string to None, parse numeric strings
2729
+ if is_subclass_of(
2730
+ ftype, (int, float), require_optional=True
2731
+ ) and isinstance(value, str):
2732
+ if value == "" or value.lower() in ("none", "null"):
2733
+ value = None
2734
+ else:
2735
+ try:
2736
+ value = float(value)
2737
+ except (ValueError, TypeError):
2738
+ pass
2739
+ pydantic_kwargs[field] = value
2740
+
2741
+ # copied from main_widget
2742
+ # file open/select dialog
2743
+ def open_file_dialog(self, default_path, type, filter="All Files (*)"):
2744
+ if type == "dir":
2745
+ return self.open_dialog(
2746
+ "select a directory", str(default_path), type, filter
2747
+ )
2748
+ elif type == "file":
2749
+ return self.open_dialog(
2750
+ "select a file", str(default_path), type, filter
2751
+ )
2752
+ elif type == "files":
2753
+ return self.open_dialog(
2754
+ "select file(s)", str(default_path), type, filter
2755
+ )
2756
+ elif type == "save":
2757
+ return self.open_dialog(
2758
+ "save a file", str(default_path), type, filter
2759
+ )
2760
+ else:
2761
+ return self.open_dialog(
2762
+ "select a directory", str(default_path), type, filter
2763
+ )
2764
+
2765
+ def open_dialog(self, title, ref, type, filter="All Files (*)"):
2766
+ """
2767
+ opens pop-up dialogue for the user to choose a specific file or directory.
2768
+
2769
+ Parameters
2770
+ ----------
2771
+ title: (str) message to display at the top of the pop up
2772
+ ref: (str) reference path to start the search at
2773
+ type: (str) type of file the user is choosing (dir, file, or save)
2774
+
2775
+ Returns
2776
+ -------
2777
+
2778
+ """
2779
+
2780
+ options = QFileDialog.DontUseNativeDialog
2781
+ if type == "dir":
2782
+ path = QFileDialog.getExistingDirectory(
2783
+ None, title, ref, options=options
2784
+ )
2785
+ elif type == "file":
2786
+ path = QFileDialog.getOpenFileName(
2787
+ None, title, ref, filter=filter, options=options
2788
+ )[0]
2789
+ elif type == "files":
2790
+ path = QFileDialog.getOpenFileNames(
2791
+ None, title, ref, filter=filter, options=options
2792
+ )[0]
2793
+ elif type == "save":
2794
+ path = QFileDialog.getSaveFileName(
2795
+ None, "Choose a save name", ref, filter=filter, options=options
2796
+ )[0]
2797
+ else:
2798
+ raise ValueError("Did not understand file dialogue type")
2799
+
2800
+ return path
2801
+
2802
+
2803
+ class MyWorker:
2804
+ """This worker class manages the jobs queue arriving from the GUI and passes to job manager, the task and update function"""
2805
+
2806
+ def __init__(self, formLayout, tab_recon: Ui_ReconTab_Form, parentForm):
2807
+ super().__init__()
2808
+ self.formLayout: QFormLayout = formLayout
2809
+ self.tab_recon: Ui_ReconTab_Form = tab_recon
2810
+ self.ui: QWidget = parentForm
2811
+ self.max_cores = (
2812
+ 1 # os.cpu_count() - no multi-threading // parallelization
2813
+ )
2814
+ # In the case of CLI, we just need to submit requests in a non-blocking way
2815
+ self.threadPool = 1 # int(self.max_cores / 2)
2816
+ self.results = {}
2817
+ self.clearResults = True
2818
+ self.executor = None
2819
+ # https://click.palletsprojects.com/en/stable/testing/
2820
+ # self.runner = CliRunner()
2821
+ self.isInitialized = False
2822
+ self.initialize()
2823
+
2824
+ def initialize(self):
2825
+ if not self.isInitialized:
2826
+ self.workerThreadRowDeletion = RowDeletionWorkerThread(
2827
+ self.formLayout
2828
+ )
2829
+ self.workerThreadRowDeletion.removeRowSignal.connect(
2830
+ self.tab_recon.remove_row
2831
+ )
2832
+ self.workerThreadRowDeletion.start()
2833
+ self.isInitialized = True
2834
+
2835
+ def set_new_instances(self, formLayout, tab_recon, parentForm):
2836
+ self.formLayout: QFormLayout = formLayout
2837
+ self.tab_recon: Ui_ReconTab_Form = tab_recon
2838
+ self.ui: QWidget = parentForm
2839
+ self.workerThreadRowDeletion.set_new_instances(formLayout)
2840
+
2841
+ def find_widget_row_in_layout(self, strID):
2842
+ layout: QFormLayout = self.formLayout
2843
+ for idx in range(0, layout.rowCount()):
2844
+ widgetItem = layout.itemAt(idx)
2845
+ name_widget = widgetItem.widget()
2846
+ toolTip_string = str(name_widget.toolTip)
2847
+ if strID in toolTip_string:
2848
+ name_widget.setParent(None)
2849
+ return idx
2850
+ return -1
2851
+
2852
+ def get_max_CPU_cores(self):
2853
+ return self.max_cores
2854
+
2855
+ def set_pool_threads(self, t):
2856
+ if t > 0 and t < self.max_cores:
2857
+ self.threadPool = t
2858
+
2859
+ def start_pool(self):
2860
+ if self.executor is None:
2861
+ self.executor = ThreadPoolExecutor(max_workers=self.threadPool)
2862
+
2863
+ def shut_down_pool(self):
2864
+ self.executor.shutdown(wait=True, cancel_futures=False)
2865
+
2866
+ def table_update_and_cleaup_thread(self, expIdx: str = "", msg: str = ""):
2867
+ """This GUI update function is passed to job manager when adding a job and updates based on stdout"""
2868
+ # called by the subprocess thread to update GUI
2869
+ if expIdx != "":
2870
+ params = self.results[expIdx]
2871
+
2872
+ _infoBox: ScrollableLabel = params["table_entry_infoBox"]
2873
+ _cancelJobBtn: PushButton = params["cancelJobButton"]
2874
+
2875
+ _infoBox.setText(_infoBox.getText() + "\n" + msg)
2876
+
2877
+ if not _cancelJobBtn.enabled:
2878
+ _cancelJobBtn.clicked.connect(
2879
+ lambda: self.cancel_job(expIdx, _infoBox, _cancelJobBtn)
2880
+ )
2881
+ _cancelJobBtn.enabled = True
2882
+
2883
+ def cancel_job(self, expIdx, _infoBox, _cancelJobBtn):
2884
+ # called to cancel a job
2885
+ if self.tab_recon.confirm_dialog():
2886
+ _cancelJobBtn.enabled = False
2887
+ _infoBox.setText(_infoBox.getText() + "\n" + "Cancelled by User")
2888
+ self.tab_recon.job_manager.cancel_job(expIdx)
2889
+
2890
+ def process_ending(self, expIdx, exit_code=0):
2891
+ # called when the subprocess ends - can be success or fail
2892
+ # Read reconstruction data
2893
+ # Can be attemped even when fail return code
2894
+ params = self.results[expIdx]
2895
+ _infoBox: ScrollableLabel = params["table_entry_infoBox"]
2896
+
2897
+ showData_thread = ShowDataWorkerThread(params["output_path"])
2898
+ showData_thread.show_data_signal.connect(self.tab_recon.show_dataset)
2899
+ showData_thread.start()
2900
+
2901
+ if (
2902
+ self.clearResults == True and exit_code == 0
2903
+ ): # remove processing entry when exiting without error
2904
+ ROW_POP_QUEUE.append(expIdx)
2905
+ else:
2906
+ _infoBox.setText(
2907
+ _infoBox.getText()
2908
+ + "\n"
2909
+ + "Process ended with return code {code}".format(
2910
+ code=exit_code
2911
+ )
2912
+ )
2913
+
2914
+ _infoBox.setText(_infoBox.getText() + "\n" + "Displaying data")
2915
+ # Wait for show thread to finish
2916
+ if showData_thread is not None:
2917
+ while showData_thread.isRunning():
2918
+ time.sleep(1)
2919
+
2920
+ def run_in_pool(self, params):
2921
+ self.start_pool()
2922
+
2923
+ self.results[params["exp_id"]] = {}
2924
+ self.results[params["exp_id"]] = params
2925
+
2926
+ try:
2927
+ # when a request on the listening port arrives with an empty path
2928
+ # we can assume the processing was initiated outside this application
2929
+ # we do not proceed with the processing and will display the results
2930
+ if params["input_path"] != "":
2931
+ self.executor.submit(self.run, params)
2932
+ except Exception as exc:
2933
+ self.results[params["exp_id"]]["error"] = str("\n".join(exc.args))
2934
+
2935
+ def run_multi_in_pool(self, multi_params_as_list):
2936
+ for params in multi_params_as_list:
2937
+ self.results[params["exp_id"]] = {}
2938
+ self.results[params["exp_id"]] = params
2939
+
2940
+ self.executor.map(self.run, multi_params_as_list)
2941
+
2942
+ def get_results(self):
2943
+ return self.results
2944
+
2945
+ def get_result(self, exp_id):
2946
+ return self.results[exp_id]
2947
+
2948
+ def run(self, params):
2949
+ # thread where work is passed to CLI which will handle the
2950
+ # multi-processing aspects as Jobs
2951
+ if params["exp_id"] not in self.results.keys():
2952
+ self.results[params["exp_id"]] = {}
2953
+ self.results[params["exp_id"]] = params
2954
+
2955
+ self.run_in_subprocess(params)
2956
+
2957
+ def run_in_subprocess(self, params):
2958
+ """function that initiates the processing on the CLI in subprocess"""
2959
+ try:
2960
+ input_path = str(params["input_path"])
2961
+ config_path = str(params["config_path"])
2962
+ output_path = str(params["output_path"])
2963
+ uid = str(params["exp_id"])
2964
+
2965
+ cmd = [
2966
+ "waveorder",
2967
+ "reconstruct",
2968
+ "-i",
2969
+ input_path,
2970
+ "-c",
2971
+ config_path,
2972
+ "-o",
2973
+ output_path,
2974
+ "-uid",
2975
+ uid,
2976
+ ]
2977
+
2978
+ self.tab_recon.job_manager.run_job(
2979
+ params["exp_id"],
2980
+ cmd,
2981
+ self.table_update_and_cleaup_thread,
2982
+ self.process_ending,
2983
+ )
2984
+
2985
+ except Exception as exc:
2986
+ self.results[params["exp_id"]]["error"] = (
2987
+ str(" ".join(cmd)) + "\n" + str("\n".join(exc.args))
2988
+ )
2989
+
2990
+
2991
+ class ShowDataWorkerThread(QThread):
2992
+ """Worker thread for sending signal for adding component when request comes
2993
+ from a different thread"""
2994
+
2995
+ show_data_signal = Signal(str)
2996
+
2997
+ def __init__(self, path):
2998
+ super().__init__()
2999
+ self.path = path
3000
+
3001
+ def run(self):
3002
+ # Emit the signal to add the widget to the main thread
3003
+ self.show_data_signal.emit(self.path)
3004
+
3005
+
3006
+ class AddOTFTableEntryWorkerThread(QThread):
3007
+ """Worker thread for sending signal for adding component when request comes
3008
+ from a different thread"""
3009
+
3010
+ add_tableOTFentry_signal = Signal(str, bool, bool)
3011
+
3012
+ def __init__(self, OTF_dir_path, bool_msg, doCheck=False):
3013
+ super().__init__()
3014
+ self.OTF_dir_path = OTF_dir_path
3015
+ self.bool_msg = bool_msg
3016
+ self.doCheck = doCheck
3017
+
3018
+ def run(self):
3019
+ # Emit the signal to add the widget to the main thread
3020
+ self.add_tableOTFentry_signal.emit(
3021
+ self.OTF_dir_path, self.bool_msg, self.doCheck
3022
+ )
3023
+
3024
+
3025
+ class AddTableEntryWorkerThread(QThread):
3026
+ """Worker thread for sending signal for adding component when request comes
3027
+ from a different thread"""
3028
+
3029
+ add_tableentry_signal = Signal(str, str, dict)
3030
+
3031
+ def __init__(self, expID, desc, params):
3032
+ super().__init__()
3033
+ self.expID = expID
3034
+ self.desc = desc
3035
+ self.params = params
3036
+
3037
+ def run(self):
3038
+ # Emit the signal to add the widget to the main thread
3039
+ self.add_tableentry_signal.emit(self.expID, self.desc, self.params)
3040
+
3041
+
3042
+ class AddWidgetWorkerThread(QThread):
3043
+ """Worker thread for sending signal for adding component when request comes
3044
+ from a different thread"""
3045
+
3046
+ add_widget_signal = Signal(QVBoxLayout, str, str, str, str)
3047
+
3048
+ def __init__(self, layout, expID, jID, desc, wellName):
3049
+ super().__init__()
3050
+ self.layout = layout
3051
+ self.expID = expID
3052
+ self.jID = jID
3053
+ self.desc = desc
3054
+ self.wellName = wellName
3055
+
3056
+ def run(self):
3057
+ # Emit the signal to add the widget to the main thread
3058
+ self.add_widget_signal.emit(
3059
+ self.layout, self.expID, self.jID, self.desc, self.wellName
3060
+ )
3061
+
3062
+
3063
+ class RowDeletionWorkerThread(QThread):
3064
+ """Searches for a row based on its ID and then
3065
+ emits a signal to QFormLayout on the main thread for deletion"""
3066
+
3067
+ removeRowSignal = Signal(int, str)
3068
+
3069
+ def __init__(self, formLayout):
3070
+ super().__init__()
3071
+ self.formLayout = formLayout
3072
+
3073
+ def set_new_instances(self, formLayout):
3074
+ self.formLayout: QFormLayout = formLayout
3075
+
3076
+ # we might deal with race conditions with a shrinking table
3077
+ # find out widget and return its index
3078
+ def find_widget_row_in_layout(self, strID):
3079
+ layout: QFormLayout = self.formLayout
3080
+ for idx in range(0, layout.rowCount()):
3081
+ widgetItem = layout.itemAt(idx)
3082
+ if widgetItem is not None:
3083
+ name_widget = widgetItem.widget()
3084
+ toolTip_string = str(name_widget.toolTip)
3085
+ if strID in toolTip_string:
3086
+ name_widget.setParent(None)
3087
+ return idx
3088
+ return -1
3089
+
3090
+ def run(self):
3091
+ while True:
3092
+ if len(ROW_POP_QUEUE) > 0:
3093
+ stringID = ROW_POP_QUEUE.pop(0)
3094
+ # Emit the signal to remove the row
3095
+ deleteRow = self.find_widget_row_in_layout(stringID)
3096
+ if deleteRow > -1:
3097
+ self.removeRowSignal.emit(int(deleteRow), str(stringID))
3098
+ time.sleep(1)
3099
+ else:
3100
+ time.sleep(5)
3101
+
3102
+
3103
+ class DropButton(QPushButton):
3104
+ """A drag & drop PushButton to load model file(s)"""
3105
+
3106
+ def __init__(self, text, parent=None, recon_tab: Ui_ReconTab_Form = None):
3107
+ super().__init__(text, parent)
3108
+ self.setAcceptDrops(True)
3109
+ self.recon_tab = recon_tab
3110
+
3111
+ def dragEnterEvent(self, event):
3112
+ if event.mimeData().hasUrls():
3113
+ event.acceptProposedAction()
3114
+
3115
+ def dropEvent(self, event):
3116
+ files = []
3117
+ for url in event.mimeData().urls():
3118
+ filepath = url.toLocalFile()
3119
+ files.append(filepath)
3120
+ self.recon_tab.open_model_files(files)
3121
+
3122
+
3123
+ class DropWidget(QWidget):
3124
+ """A drag & drop widget container to load model file(s)"""
3125
+
3126
+ def __init__(self, recon_tab: Ui_ReconTab_Form = None):
3127
+ super().__init__()
3128
+ self.setAcceptDrops(True)
3129
+ self.recon_tab = recon_tab
3130
+
3131
+ def dragEnterEvent(self, event):
3132
+ if event.mimeData().hasUrls():
3133
+ event.acceptProposedAction()
3134
+
3135
+ def dropEvent(self, event):
3136
+ files = []
3137
+ for url in event.mimeData().urls():
3138
+ filepath = url.toLocalFile()
3139
+ files.append(filepath)
3140
+ self.recon_tab.open_model_files(files)
3141
+
3142
+
3143
+ class ScrollableLabel(QScrollArea):
3144
+ """A scrollable label widget used for Job entry"""
3145
+
3146
+ def __init__(self, text, *args, **kwargs):
3147
+ super().__init__(*args, **kwargs)
3148
+
3149
+ self.label = QLabel()
3150
+ self.label.setWordWrap(True)
3151
+ self.label.setText(text)
3152
+
3153
+ layout = QVBoxLayout()
3154
+ layout.setAlignment(Qt.AlignmentFlag.AlignTop)
3155
+ layout.addWidget(self.label)
3156
+ self.label.setSizePolicy(
3157
+ QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
3158
+ )
3159
+
3160
+ container = QWidget()
3161
+ container.setLayout(layout)
3162
+ container.setSizePolicy(
3163
+ QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
3164
+ )
3165
+
3166
+ self.setWidget(container)
3167
+ self.setWidgetResizable(True)
3168
+ self.setSizePolicy(
3169
+ QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
3170
+ )
3171
+ self.setAlignment(Qt.AlignmentFlag.AlignTop)
3172
+
3173
+ def setText(self, text):
3174
+ self.label.setText(text)
3175
+
3176
+ def getText(self):
3177
+ return self.label.text()
3178
+
3179
+
3180
+ class MyWidget(QWidget):
3181
+ resized = Signal()
3182
+
3183
+ def __init__(self):
3184
+ super().__init__()
3185
+
3186
+ def resizeEvent(self, event):
3187
+ self.resized.emit()
3188
+ super().resizeEvent(event)
3189
+
3190
+
3191
+ class CollapsibleBox(QWidget):
3192
+ """A collapsible widget"""
3193
+
3194
+ def __init__(
3195
+ self, title="", parent=None, hasPydanticModel=False, expanded=False
3196
+ ):
3197
+ super(CollapsibleBox, self).__init__(parent)
3198
+
3199
+ self.hasPydanticModel = hasPydanticModel
3200
+ self.toggle_button = QToolButton(
3201
+ text=title, checkable=True, checked=False
3202
+ )
3203
+ self.toggle_button.setStyleSheet("QToolButton { border: none; }")
3204
+ self.toggle_button.setToolButtonStyle(
3205
+ QtCore.Qt.ToolButtonStyle.ToolButtonTextBesideIcon
3206
+ )
3207
+ self.toggle_button.setArrowType(QtCore.Qt.ArrowType.RightArrow)
3208
+ self.toggle_button.pressed.connect(self.on_pressed)
3209
+
3210
+ self.toggle_animation = QtCore.QParallelAnimationGroup(self)
3211
+
3212
+ self.content_area = QScrollArea(maximumHeight=0, minimumHeight=0)
3213
+ self.content_area.setSizePolicy(
3214
+ QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
3215
+ )
3216
+ self.content_area.setFrameShape(QFrame.Shape.NoFrame)
3217
+
3218
+ lay = QVBoxLayout(self)
3219
+ lay.setSpacing(0)
3220
+ lay.setContentsMargins(0, 0, 0, 0)
3221
+ lay.addWidget(self.toggle_button)
3222
+ lay.addWidget(self.content_area)
3223
+
3224
+ self.toggle_animation.addAnimation(
3225
+ QtCore.QPropertyAnimation(self, b"minimumHeight")
3226
+ )
3227
+ self.toggle_animation.addAnimation(
3228
+ QtCore.QPropertyAnimation(self, b"maximumHeight")
3229
+ )
3230
+ self.toggle_animation.addAnimation(
3231
+ QtCore.QPropertyAnimation(self.content_area, b"maximumHeight")
3232
+ )
3233
+ collapsed_height = (
3234
+ self.sizeHint().height() - self.content_area.maximumHeight()
3235
+ )
3236
+
3237
+ content_height = lay.sizeHint().height()
3238
+ for i in range(self.toggle_animation.animationCount()):
3239
+ animation = self.toggle_animation.animationAt(i)
3240
+ animation.setDuration(500)
3241
+ animation.setStartValue(collapsed_height)
3242
+ animation.setEndValue(collapsed_height + content_height)
3243
+
3244
+ if expanded:
3245
+ self.on_pressed()
3246
+
3247
+ def setNewName(self, name):
3248
+ self.toggle_button.setText(name)
3249
+
3250
+ def on_pressed(self):
3251
+ checked = self.toggle_button.isChecked()
3252
+ self.toggle_button.setArrowType(
3253
+ QtCore.Qt.ArrowType.DownArrow
3254
+ if not checked
3255
+ else QtCore.Qt.ArrowType.RightArrow
3256
+ )
3257
+ self.toggle_animation.setDirection(
3258
+ QtCore.QAbstractAnimation.Direction.Forward
3259
+ if not checked
3260
+ else QtCore.QAbstractAnimation.Direction.Backward
3261
+ )
3262
+ self.toggle_animation.start()
3263
+ if checked and self.hasPydanticModel:
3264
+ # do model verification on close
3265
+ pass
3266
+
3267
+ def setContentLayout(self, layout):
3268
+ lay = self.content_area.layout()
3269
+ del lay
3270
+ self.content_area.setLayout(layout)
3271
+ collapsed_height = (
3272
+ self.sizeHint().height() - self.content_area.maximumHeight()
3273
+ )
3274
+ content_height = layout.sizeHint().height()
3275
+ for i in range(self.toggle_animation.animationCount()):
3276
+ animation = self.toggle_animation.animationAt(i)
3277
+ animation.setDuration(500)
3278
+ animation.setStartValue(collapsed_height)
3279
+ animation.setEndValue(collapsed_height + content_height)
3280
+
3281
+ content_animation = self.toggle_animation.animationAt(
3282
+ self.toggle_animation.animationCount() - 1
3283
+ )
3284
+ content_animation.setDuration(500)
3285
+ content_animation.setStartValue(0)
3286
+ content_animation.setEndValue(content_height)
3287
+
3288
+
3289
+ # VScode debugging
3290
+ if __name__ == "__main__":
3291
+ import napari
3292
+
3293
+ napari.Viewer()
3294
+ napari.run()