synapse_net.tools.base_widget

  1import os
  2import sys
  3from contextlib import contextmanager
  4from pathlib import Path
  5
  6import napari
  7import numpy as np
  8import qtpy.QtWidgets as QtWidgets
  9
 10from napari.utils.notifications import show_info
 11from qtpy.QtCore import Qt
 12from qtpy.QtWidgets import (
 13    QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSpinBox, QComboBox, QCheckBox
 14)
 15from superqt import QCollapsible
 16
 17try:
 18    from napari_skimage_regionprops import add_table, get_table
 19except ImportError:
 20    add_table, get_table = None, None
 21
 22
 23class _SilencePrint:
 24    def __enter__(self):
 25        self._original_stdout = sys.stdout
 26        sys.stdout = open(os.devnull, "w")
 27
 28    def __exit__(self, exc_type, exc_val, exc_tb):
 29        sys.stdout.close()
 30        sys.stdout = self._original_stdout
 31
 32
 33class BaseWidget(QWidget):
 34    def __init__(self):
 35        super().__init__()
 36        self.viewer = napari.current_viewer()
 37        self.attribute_dict = {}
 38
 39    def _create_layer_selector(self, selector_name, layer_type="Image", prefer_substring=None):
 40        """Create a layer selector for an image or labels and store it in a dictionary.
 41
 42        Args:
 43            selector_name (str): The name of the selector, used as a key in the dictionary.
 44            layer_type (str): The type of layer to filter for ("Image" or "Labels").
 45            prefer_substring (str, optional): If given, the selector auto-defaults to the first
 46                layer whose name contains this substring (case-insensitive); falls back to the first
 47                layer otherwise. Applied only when there is no valid current selection (initial
 48                population or the selected layer was removed); a user's manual choice is preserved.
 49        """
 50        if not hasattr(self, "layer_selectors"):
 51            self.layer_selectors = {}
 52
 53        # Determine the annotation type for the widget
 54        if layer_type == "Image":
 55            layer_filter = napari.layers.Image
 56        elif layer_type == "Labels":
 57            layer_filter = napari.layers.Labels
 58        elif layer_type == "Shapes":
 59            layer_filter = napari.layers.Shapes
 60        else:
 61            raise ValueError("layer_type must be either 'Image' or 'Labels'.")
 62
 63        selector_widget = QtWidgets.QWidget()
 64        image_selector = QtWidgets.QComboBox()
 65        layer_label = QtWidgets.QLabel(f"{selector_name}:")
 66
 67        # Populate initial options
 68        self._update_selector(selector=image_selector, layer_filter=layer_filter, prefer_substring=prefer_substring)
 69
 70        # Update selector on layer events
 71        self.viewer.layers.events.inserted.connect(
 72            lambda event: self._update_selector(image_selector, layer_filter, prefer_substring)
 73        )
 74        self.viewer.layers.events.removed.connect(
 75            lambda event: self._update_selector(image_selector, layer_filter, prefer_substring)
 76        )
 77
 78        # Store the selector in the dictionary
 79        self.layer_selectors[selector_name] = selector_widget
 80
 81        # Set up layout
 82        layout = QVBoxLayout()
 83        layout.addWidget(layer_label)
 84        layout.addWidget(image_selector)
 85        selector_widget.setLayout(layout)
 86        return selector_widget
 87
 88    def _update_selector(self, selector, layer_filter, prefer_substring=None):
 89        """Update a single selector with the current image layers in the viewer.
 90
 91        The user's current selection is preserved if that layer still exists. Only when there is
 92        no valid current selection (initial population, or the selected layer was removed) does the
 93        default apply: the first layer whose name contains ``prefer_substring`` (case-insensitive)
 94        if given, otherwise the first layer (QComboBox default).
 95        """
 96        previous = selector.currentText()
 97        selector.clear()
 98        image_layers = [layer.name for layer in self.viewer.layers if isinstance(layer, layer_filter)]
 99        selector.addItems(image_layers)
100        if previous in image_layers:
101            selector.setCurrentText(previous)
102        elif prefer_substring:
103            needle = prefer_substring.lower()
104            match = next((name for name in image_layers if needle in name.lower()), None)
105            if match is not None:
106                selector.setCurrentText(match)
107
108    @contextmanager
109    def _computing(self, button, busy_text, idle_text, message):
110        """Show a busy state around a synchronous, GUI-thread-blocking action, then restore it.
111
112        Disables and relabels ``button``, sets a wait cursor, shows ``message`` and forces one repaint
113        so the busy state is painted *before* the blocking call — otherwise none of it would render
114        until the call returned and the button would just look stuck. The cursor, button label and
115        enabled state are restored on exit (also on error). Disabling the button also blocks a
116        re-entrant second click while the action is in flight. All Qt calls are guarded so they no-op
117        without a running QApplication.
118        """
119        app = QApplication.instance()
120        button.setEnabled(False)
121        button.setText(busy_text)
122        if app is not None:
123            QApplication.setOverrideCursor(Qt.WaitCursor)
124        show_info(message)
125        if app is not None:
126            app.processEvents()
127        try:
128            yield
129        finally:
130            if app is not None:
131                QApplication.restoreOverrideCursor()
132            button.setEnabled(True)
133            button.setText(idle_text)
134
135    def _get_layer_selector_layer(self, selector_name):
136        """Return the layer currently selected in a given selector."""
137        if selector_name in self.layer_selectors:
138            selector_widget = self.layer_selectors[selector_name]
139
140            # Retrieve the QComboBox from the QWidget's layout
141            image_selector = selector_widget.layout().itemAt(1).widget()
142
143            if isinstance(image_selector, QComboBox):
144                selected_layer_name = image_selector.currentText()
145                if selected_layer_name in self.viewer.layers:
146                    return self.viewer.layers[selected_layer_name]
147        return None  # Return None if layer not found
148
149    def _get_layer_selector_data(self, selector_name, return_metadata=False):
150        """Return the data for the layer currently selected in a given selector."""
151        if selector_name in self.layer_selectors:
152            selector_widget = self.layer_selectors[selector_name]
153
154            # Retrieve the QComboBox from the QWidget's layout
155            image_selector = selector_widget.layout().itemAt(1).widget()
156
157            if isinstance(image_selector, QComboBox):
158                selected_layer_name = image_selector.currentText()
159                if selected_layer_name in self.viewer.layers:
160                    if return_metadata:
161                        return self.viewer.layers[selected_layer_name].metadata
162                    else:
163                        return self.viewer.layers[selected_layer_name].data
164        return None  # Return None if layer not found
165
166    def _add_string_param(self, name, value, title=None, placeholder=None, layout=None, tooltip=None):
167        if layout is None:
168            layout = QtWidgets.QHBoxLayout()
169        label = QtWidgets.QLabel(title or name)
170        if tooltip:
171            label.setToolTip(tooltip)
172        layout.addWidget(label)
173        param = QtWidgets.QLineEdit()
174        param.setText(value)
175        if placeholder is not None:
176            param.setPlaceholderText(placeholder)
177        param.textChanged.connect(lambda val: setattr(self, name, val))
178        if tooltip:
179            param.setToolTip(tooltip)
180        layout.addWidget(param)
181        return param, layout
182
183    def _add_float_param(self, name, value, title=None, min_val=0.0, max_val=1.0, decimals=2,
184                         step=0.01, layout=None, tooltip=None):
185        if layout is None:
186            layout = QtWidgets.QHBoxLayout()
187        label = QtWidgets.QLabel(title or name)
188        if tooltip:
189            label.setToolTip(tooltip)
190        layout.addWidget(label)
191        param = QtWidgets.QDoubleSpinBox()
192        param.setRange(min_val, max_val)
193        param.setDecimals(decimals)
194        param.setValue(value)
195        param.setSingleStep(step)
196        param.valueChanged.connect(lambda val: setattr(self, name, val))
197        if tooltip:
198            param.setToolTip(tooltip)
199        layout.addWidget(param)
200        return param, layout
201
202    def _add_int_param(self, name, value, min_val, max_val, title=None, step=1, layout=None, tooltip=None):
203        if layout is None:
204            layout = QHBoxLayout()
205        label = QLabel(title or name)
206        if tooltip:
207            label.setToolTip(tooltip)
208        layout.addWidget(label)
209        param = QSpinBox()
210        param.setRange(min_val, max_val)
211        param.setValue(value)
212        param.setSingleStep(step)
213        param.valueChanged.connect(lambda val: setattr(self, name, val))
214        if tooltip:
215            param.setToolTip(tooltip)
216        layout.addWidget(param)
217        return param, layout
218
219    def _add_choice_param(self, name, value, options, title=None, layout=None, update=None, tooltip=None):
220        if layout is None:
221            layout = QHBoxLayout()
222        label = QLabel(title or name)
223        if tooltip:
224            label.setToolTip(tooltip)
225        layout.addWidget(label)
226
227        # Create the dropdown menu via QComboBox, set the available values.
228        dropdown = QComboBox()
229        dropdown.addItems(options)
230        if update is None:
231            dropdown.currentIndexChanged.connect(lambda index: setattr(self, name, options[index]))
232        else:
233            dropdown.currentIndexChanged.connect(update)
234
235        # Set the correct value for the value.
236        dropdown.setCurrentIndex(dropdown.findText(value))
237
238        if tooltip:
239            dropdown.setToolTip(tooltip)
240
241        layout.addWidget(dropdown)
242        return dropdown, layout
243
244    def _add_shape_param(self, names, values, min_val, max_val, step=1, title=None, tooltip=None):
245        layout = QHBoxLayout()
246
247        x_layout = QVBoxLayout()
248        x_param, _ = self._add_int_param(
249            names[0], values[0], min_val=min_val, max_val=max_val, layout=x_layout, step=step,
250            title=title[0] if title is not None else title, tooltip=tooltip
251        )
252        layout.addLayout(x_layout)
253
254        y_layout = QVBoxLayout()
255        y_param, _ = self._add_int_param(
256            names[1], values[1], min_val=min_val, max_val=max_val, layout=y_layout, step=step,
257            title=title[1] if title is not None else title, tooltip=tooltip
258        )
259        layout.addLayout(y_layout)
260
261        if len(names) == 3:
262            z_layout = QVBoxLayout()
263            z_param, _ = self._add_int_param(
264                names[2], values[2], min_val=min_val, max_val=max_val, layout=z_layout, step=step,
265                title=title[2] if title is not None else title, tooltip=tooltip
266            )
267            layout.addLayout(z_layout)
268            return x_param, y_param, z_param, layout
269
270        return x_param, y_param, layout
271
272    def _make_collapsible(self, widget, title):
273        parent_widget = QWidget()
274        parent_widget.setLayout(QVBoxLayout())
275        collapsible = QCollapsible(title, parent_widget)
276        collapsible.addWidget(widget)
277        parent_widget.layout().addWidget(collapsible)
278        return parent_widget
279
280    def _add_boolean_param(self, name, value, title=None, tooltip=None):
281        checkbox = QCheckBox(name if title is None else title)
282        checkbox.setChecked(value)
283        checkbox.stateChanged.connect(lambda val: setattr(self, name, val))
284        if tooltip:
285            checkbox.setToolTip(tooltip)
286        return checkbox
287
288    def _add_path_param(self, name, value, select_type, title=None, placeholder=None, tooltip=None):
289        assert select_type in ("directory", "file", "both")
290
291        layout = QtWidgets.QHBoxLayout()
292        label = QtWidgets.QLabel(title or name)
293        if tooltip:
294            label.setToolTip(tooltip)
295        layout.addWidget(label)
296
297        path_textbox = QtWidgets.QLineEdit()
298        path_textbox.setText(str(value))
299        if placeholder is not None:
300            path_textbox.setPlaceholderText(placeholder)
301        path_textbox.textChanged.connect(lambda val: setattr(self, name, val))
302        if tooltip:
303            path_textbox.setToolTip(tooltip)
304
305        layout.addWidget(path_textbox)
306
307        def add_path_button(select_type, tooltip=None):
308            # Adjust button text.
309            button_text = f"Select {select_type.capitalize()}"
310            path_button = QtWidgets.QPushButton(button_text)
311
312            # Call appropriate function based on select_type.
313            path_button.clicked.connect(lambda: getattr(self, f"_get_{select_type}_path")(name, path_textbox))
314            if tooltip:
315                path_button.setToolTip(tooltip)
316            layout.addWidget(path_button)
317
318        if select_type == "both":
319            add_path_button("file")
320            add_path_button("directory")
321
322        else:
323            add_path_button(select_type)
324
325        return path_textbox, layout
326
327    def _get_directory_path(self, name, textbox, tooltip=None):
328        directory = QtWidgets.QFileDialog.getExistingDirectory(
329            self, "Select Directory", "", QtWidgets.QFileDialog.ShowDirsOnly
330        )
331        if tooltip:
332            directory.setToolTip(tooltip)
333        if directory and Path(directory).is_dir():
334            textbox.setText(str(directory))
335        else:
336            # Handle the case where the selected path is not a directory
337            print("Invalid directory selected. Please try again.")
338
339    def _get_file_path(self, name, textbox, tooltip=None):
340        file_path, _ = QtWidgets.QFileDialog.getOpenFileName(
341            self, "Select File", "", "All Files (*)"
342        )
343        if tooltip:
344            file_path.setToolTip(tooltip)
345        if file_path and Path(file_path).is_file():
346            textbox.setText(str(file_path))
347        else:
348            # Handle the case where the selected path is not a file
349            print("Invalid file selected. Please try again.")
350
351    def _handle_resolution(self, metadata, voxel_size_param, ndim, return_as_list=True):
352        # Get the resolution / voxel size from the layer metadata if available.
353        resolution = metadata.get("voxel_size", None)
354
355        # If user input was given then override resolution from metadata.
356        axes = "zyx" if ndim == 3 else "yx"
357        if voxel_size_param.value() != 0.0:  # Changed from default.
358            resolution = {ax: voxel_size_param.value() for ax in axes}
359
360        if resolution is not None and return_as_list:
361            resolution = [resolution[ax] for ax in axes]
362            assert len(resolution) == ndim
363
364        return resolution
365
366    def _save_table(self, save_path, data):
367        ext = os.path.splitext(save_path)[1]
368        if ext == "":  # No file extension given, By default we save to CSV.
369            file_path = f"{save_path}.csv"
370            data.to_csv(file_path, index=False)
371        elif ext == ".csv":  # Extension was specified as csv
372            file_path = save_path
373            data.to_csv(file_path, index=False)
374        elif ext == ".xlsx":  # We also support excel.
375            file_path = save_path
376            data.to_excel(file_path, index=False)
377        else:
378            raise ValueError("Invalid extension for table: {ext}. We support .csv or .xlsx.")
379        return file_path
380
381    def _add_properties_and_table(self, layer, table_data, save_path=""):
382        layer.properties = table_data
383
384        if add_table is not None:
385            with _SilencePrint():
386                add_table(layer, self.viewer)
387
388        # Save table to file if save path is provided.
389        if save_path != "":
390            file_path = self._save_table(self.save_path.text(), table_data)
391            show_info(f"INFO: Added table and saved file to {file_path}.")
392
393    def _add_or_update_layer(self, add_fn, name, data, scale, translate, layer_kwargs):
394        """Add a layer via ``add_fn`` (e.g. ``self.viewer.add_labels``), or refresh it in place if a
395        layer with this name already exists.
396
397        On refresh the ``scale``/``translate`` are reapplied (when provided) because a persisted layer
398        keeps its original transform, which may be stale if the source layer / voxel size changed
399        between runs; any ``layer_kwargs`` (e.g. ``opacity``, ``blending``, ``colormap``) are reapplied
400        too. On first add these go through the layer constructor. Returns the (new or existing) layer.
401        """
402        if name in self.viewer.layers:
403            layer = self.viewer.layers[name]
404            layer.data = data
405            if scale is not None:
406                layer.scale = scale
407            if translate is not None:
408                layer.translate = translate
409            for key, value in layer_kwargs.items():
410                setattr(layer, key, value)
411        else:
412            ctor_kwargs = dict(layer_kwargs)
413            if scale is not None:
414                ctor_kwargs["scale"] = scale
415            if translate is not None:
416                ctor_kwargs["translate"] = translate
417            layer = add_fn(data, name=name, **ctor_kwargs)
418        return layer
419
420    def add_or_update_labels(self, name, data, *, scale=None, translate=None, **layer_kwargs):
421        """Add a Labels layer, or refresh it in place if one with this name already exists.
422
423        See :meth:`_add_or_update_layer` for the refresh/transform semantics. Extra keyword arguments
424        (``opacity``, ``blending``, ``colormap``, …) are forwarded to the layer. Returns the layer.
425        """
426        return self._add_or_update_layer(
427            self.viewer.add_labels, name, data, scale, translate, layer_kwargs
428        )
429
430    def add_or_update_surface(self, name, vertices, faces, *, scale=None, translate=None,
431                              values=None, **layer_kwargs):
432        """Add a Surface layer, or refresh it in place if one with this name already exists.
433
434        Surface layers colour by per-vertex ``values``; a constant array (the default) gives a
435        flat-coloured surface. See :meth:`_add_or_update_layer` for the refresh/transform semantics.
436        Returns the layer.
437        """
438        if values is None:
439            values = np.ones(len(vertices), dtype="float32")
440        return self._add_or_update_layer(
441            self.viewer.add_surface, name, (vertices, faces, values), scale, translate, layer_kwargs
442        )
class BaseWidget(PyQt5.QtWidgets.QWidget):
 34class BaseWidget(QWidget):
 35    def __init__(self):
 36        super().__init__()
 37        self.viewer = napari.current_viewer()
 38        self.attribute_dict = {}
 39
 40    def _create_layer_selector(self, selector_name, layer_type="Image", prefer_substring=None):
 41        """Create a layer selector for an image or labels and store it in a dictionary.
 42
 43        Args:
 44            selector_name (str): The name of the selector, used as a key in the dictionary.
 45            layer_type (str): The type of layer to filter for ("Image" or "Labels").
 46            prefer_substring (str, optional): If given, the selector auto-defaults to the first
 47                layer whose name contains this substring (case-insensitive); falls back to the first
 48                layer otherwise. Applied only when there is no valid current selection (initial
 49                population or the selected layer was removed); a user's manual choice is preserved.
 50        """
 51        if not hasattr(self, "layer_selectors"):
 52            self.layer_selectors = {}
 53
 54        # Determine the annotation type for the widget
 55        if layer_type == "Image":
 56            layer_filter = napari.layers.Image
 57        elif layer_type == "Labels":
 58            layer_filter = napari.layers.Labels
 59        elif layer_type == "Shapes":
 60            layer_filter = napari.layers.Shapes
 61        else:
 62            raise ValueError("layer_type must be either 'Image' or 'Labels'.")
 63
 64        selector_widget = QtWidgets.QWidget()
 65        image_selector = QtWidgets.QComboBox()
 66        layer_label = QtWidgets.QLabel(f"{selector_name}:")
 67
 68        # Populate initial options
 69        self._update_selector(selector=image_selector, layer_filter=layer_filter, prefer_substring=prefer_substring)
 70
 71        # Update selector on layer events
 72        self.viewer.layers.events.inserted.connect(
 73            lambda event: self._update_selector(image_selector, layer_filter, prefer_substring)
 74        )
 75        self.viewer.layers.events.removed.connect(
 76            lambda event: self._update_selector(image_selector, layer_filter, prefer_substring)
 77        )
 78
 79        # Store the selector in the dictionary
 80        self.layer_selectors[selector_name] = selector_widget
 81
 82        # Set up layout
 83        layout = QVBoxLayout()
 84        layout.addWidget(layer_label)
 85        layout.addWidget(image_selector)
 86        selector_widget.setLayout(layout)
 87        return selector_widget
 88
 89    def _update_selector(self, selector, layer_filter, prefer_substring=None):
 90        """Update a single selector with the current image layers in the viewer.
 91
 92        The user's current selection is preserved if that layer still exists. Only when there is
 93        no valid current selection (initial population, or the selected layer was removed) does the
 94        default apply: the first layer whose name contains ``prefer_substring`` (case-insensitive)
 95        if given, otherwise the first layer (QComboBox default).
 96        """
 97        previous = selector.currentText()
 98        selector.clear()
 99        image_layers = [layer.name for layer in self.viewer.layers if isinstance(layer, layer_filter)]
100        selector.addItems(image_layers)
101        if previous in image_layers:
102            selector.setCurrentText(previous)
103        elif prefer_substring:
104            needle = prefer_substring.lower()
105            match = next((name for name in image_layers if needle in name.lower()), None)
106            if match is not None:
107                selector.setCurrentText(match)
108
109    @contextmanager
110    def _computing(self, button, busy_text, idle_text, message):
111        """Show a busy state around a synchronous, GUI-thread-blocking action, then restore it.
112
113        Disables and relabels ``button``, sets a wait cursor, shows ``message`` and forces one repaint
114        so the busy state is painted *before* the blocking call — otherwise none of it would render
115        until the call returned and the button would just look stuck. The cursor, button label and
116        enabled state are restored on exit (also on error). Disabling the button also blocks a
117        re-entrant second click while the action is in flight. All Qt calls are guarded so they no-op
118        without a running QApplication.
119        """
120        app = QApplication.instance()
121        button.setEnabled(False)
122        button.setText(busy_text)
123        if app is not None:
124            QApplication.setOverrideCursor(Qt.WaitCursor)
125        show_info(message)
126        if app is not None:
127            app.processEvents()
128        try:
129            yield
130        finally:
131            if app is not None:
132                QApplication.restoreOverrideCursor()
133            button.setEnabled(True)
134            button.setText(idle_text)
135
136    def _get_layer_selector_layer(self, selector_name):
137        """Return the layer currently selected in a given selector."""
138        if selector_name in self.layer_selectors:
139            selector_widget = self.layer_selectors[selector_name]
140
141            # Retrieve the QComboBox from the QWidget's layout
142            image_selector = selector_widget.layout().itemAt(1).widget()
143
144            if isinstance(image_selector, QComboBox):
145                selected_layer_name = image_selector.currentText()
146                if selected_layer_name in self.viewer.layers:
147                    return self.viewer.layers[selected_layer_name]
148        return None  # Return None if layer not found
149
150    def _get_layer_selector_data(self, selector_name, return_metadata=False):
151        """Return the data for the layer currently selected in a given selector."""
152        if selector_name in self.layer_selectors:
153            selector_widget = self.layer_selectors[selector_name]
154
155            # Retrieve the QComboBox from the QWidget's layout
156            image_selector = selector_widget.layout().itemAt(1).widget()
157
158            if isinstance(image_selector, QComboBox):
159                selected_layer_name = image_selector.currentText()
160                if selected_layer_name in self.viewer.layers:
161                    if return_metadata:
162                        return self.viewer.layers[selected_layer_name].metadata
163                    else:
164                        return self.viewer.layers[selected_layer_name].data
165        return None  # Return None if layer not found
166
167    def _add_string_param(self, name, value, title=None, placeholder=None, layout=None, tooltip=None):
168        if layout is None:
169            layout = QtWidgets.QHBoxLayout()
170        label = QtWidgets.QLabel(title or name)
171        if tooltip:
172            label.setToolTip(tooltip)
173        layout.addWidget(label)
174        param = QtWidgets.QLineEdit()
175        param.setText(value)
176        if placeholder is not None:
177            param.setPlaceholderText(placeholder)
178        param.textChanged.connect(lambda val: setattr(self, name, val))
179        if tooltip:
180            param.setToolTip(tooltip)
181        layout.addWidget(param)
182        return param, layout
183
184    def _add_float_param(self, name, value, title=None, min_val=0.0, max_val=1.0, decimals=2,
185                         step=0.01, layout=None, tooltip=None):
186        if layout is None:
187            layout = QtWidgets.QHBoxLayout()
188        label = QtWidgets.QLabel(title or name)
189        if tooltip:
190            label.setToolTip(tooltip)
191        layout.addWidget(label)
192        param = QtWidgets.QDoubleSpinBox()
193        param.setRange(min_val, max_val)
194        param.setDecimals(decimals)
195        param.setValue(value)
196        param.setSingleStep(step)
197        param.valueChanged.connect(lambda val: setattr(self, name, val))
198        if tooltip:
199            param.setToolTip(tooltip)
200        layout.addWidget(param)
201        return param, layout
202
203    def _add_int_param(self, name, value, min_val, max_val, title=None, step=1, layout=None, tooltip=None):
204        if layout is None:
205            layout = QHBoxLayout()
206        label = QLabel(title or name)
207        if tooltip:
208            label.setToolTip(tooltip)
209        layout.addWidget(label)
210        param = QSpinBox()
211        param.setRange(min_val, max_val)
212        param.setValue(value)
213        param.setSingleStep(step)
214        param.valueChanged.connect(lambda val: setattr(self, name, val))
215        if tooltip:
216            param.setToolTip(tooltip)
217        layout.addWidget(param)
218        return param, layout
219
220    def _add_choice_param(self, name, value, options, title=None, layout=None, update=None, tooltip=None):
221        if layout is None:
222            layout = QHBoxLayout()
223        label = QLabel(title or name)
224        if tooltip:
225            label.setToolTip(tooltip)
226        layout.addWidget(label)
227
228        # Create the dropdown menu via QComboBox, set the available values.
229        dropdown = QComboBox()
230        dropdown.addItems(options)
231        if update is None:
232            dropdown.currentIndexChanged.connect(lambda index: setattr(self, name, options[index]))
233        else:
234            dropdown.currentIndexChanged.connect(update)
235
236        # Set the correct value for the value.
237        dropdown.setCurrentIndex(dropdown.findText(value))
238
239        if tooltip:
240            dropdown.setToolTip(tooltip)
241
242        layout.addWidget(dropdown)
243        return dropdown, layout
244
245    def _add_shape_param(self, names, values, min_val, max_val, step=1, title=None, tooltip=None):
246        layout = QHBoxLayout()
247
248        x_layout = QVBoxLayout()
249        x_param, _ = self._add_int_param(
250            names[0], values[0], min_val=min_val, max_val=max_val, layout=x_layout, step=step,
251            title=title[0] if title is not None else title, tooltip=tooltip
252        )
253        layout.addLayout(x_layout)
254
255        y_layout = QVBoxLayout()
256        y_param, _ = self._add_int_param(
257            names[1], values[1], min_val=min_val, max_val=max_val, layout=y_layout, step=step,
258            title=title[1] if title is not None else title, tooltip=tooltip
259        )
260        layout.addLayout(y_layout)
261
262        if len(names) == 3:
263            z_layout = QVBoxLayout()
264            z_param, _ = self._add_int_param(
265                names[2], values[2], min_val=min_val, max_val=max_val, layout=z_layout, step=step,
266                title=title[2] if title is not None else title, tooltip=tooltip
267            )
268            layout.addLayout(z_layout)
269            return x_param, y_param, z_param, layout
270
271        return x_param, y_param, layout
272
273    def _make_collapsible(self, widget, title):
274        parent_widget = QWidget()
275        parent_widget.setLayout(QVBoxLayout())
276        collapsible = QCollapsible(title, parent_widget)
277        collapsible.addWidget(widget)
278        parent_widget.layout().addWidget(collapsible)
279        return parent_widget
280
281    def _add_boolean_param(self, name, value, title=None, tooltip=None):
282        checkbox = QCheckBox(name if title is None else title)
283        checkbox.setChecked(value)
284        checkbox.stateChanged.connect(lambda val: setattr(self, name, val))
285        if tooltip:
286            checkbox.setToolTip(tooltip)
287        return checkbox
288
289    def _add_path_param(self, name, value, select_type, title=None, placeholder=None, tooltip=None):
290        assert select_type in ("directory", "file", "both")
291
292        layout = QtWidgets.QHBoxLayout()
293        label = QtWidgets.QLabel(title or name)
294        if tooltip:
295            label.setToolTip(tooltip)
296        layout.addWidget(label)
297
298        path_textbox = QtWidgets.QLineEdit()
299        path_textbox.setText(str(value))
300        if placeholder is not None:
301            path_textbox.setPlaceholderText(placeholder)
302        path_textbox.textChanged.connect(lambda val: setattr(self, name, val))
303        if tooltip:
304            path_textbox.setToolTip(tooltip)
305
306        layout.addWidget(path_textbox)
307
308        def add_path_button(select_type, tooltip=None):
309            # Adjust button text.
310            button_text = f"Select {select_type.capitalize()}"
311            path_button = QtWidgets.QPushButton(button_text)
312
313            # Call appropriate function based on select_type.
314            path_button.clicked.connect(lambda: getattr(self, f"_get_{select_type}_path")(name, path_textbox))
315            if tooltip:
316                path_button.setToolTip(tooltip)
317            layout.addWidget(path_button)
318
319        if select_type == "both":
320            add_path_button("file")
321            add_path_button("directory")
322
323        else:
324            add_path_button(select_type)
325
326        return path_textbox, layout
327
328    def _get_directory_path(self, name, textbox, tooltip=None):
329        directory = QtWidgets.QFileDialog.getExistingDirectory(
330            self, "Select Directory", "", QtWidgets.QFileDialog.ShowDirsOnly
331        )
332        if tooltip:
333            directory.setToolTip(tooltip)
334        if directory and Path(directory).is_dir():
335            textbox.setText(str(directory))
336        else:
337            # Handle the case where the selected path is not a directory
338            print("Invalid directory selected. Please try again.")
339
340    def _get_file_path(self, name, textbox, tooltip=None):
341        file_path, _ = QtWidgets.QFileDialog.getOpenFileName(
342            self, "Select File", "", "All Files (*)"
343        )
344        if tooltip:
345            file_path.setToolTip(tooltip)
346        if file_path and Path(file_path).is_file():
347            textbox.setText(str(file_path))
348        else:
349            # Handle the case where the selected path is not a file
350            print("Invalid file selected. Please try again.")
351
352    def _handle_resolution(self, metadata, voxel_size_param, ndim, return_as_list=True):
353        # Get the resolution / voxel size from the layer metadata if available.
354        resolution = metadata.get("voxel_size", None)
355
356        # If user input was given then override resolution from metadata.
357        axes = "zyx" if ndim == 3 else "yx"
358        if voxel_size_param.value() != 0.0:  # Changed from default.
359            resolution = {ax: voxel_size_param.value() for ax in axes}
360
361        if resolution is not None and return_as_list:
362            resolution = [resolution[ax] for ax in axes]
363            assert len(resolution) == ndim
364
365        return resolution
366
367    def _save_table(self, save_path, data):
368        ext = os.path.splitext(save_path)[1]
369        if ext == "":  # No file extension given, By default we save to CSV.
370            file_path = f"{save_path}.csv"
371            data.to_csv(file_path, index=False)
372        elif ext == ".csv":  # Extension was specified as csv
373            file_path = save_path
374            data.to_csv(file_path, index=False)
375        elif ext == ".xlsx":  # We also support excel.
376            file_path = save_path
377            data.to_excel(file_path, index=False)
378        else:
379            raise ValueError("Invalid extension for table: {ext}. We support .csv or .xlsx.")
380        return file_path
381
382    def _add_properties_and_table(self, layer, table_data, save_path=""):
383        layer.properties = table_data
384
385        if add_table is not None:
386            with _SilencePrint():
387                add_table(layer, self.viewer)
388
389        # Save table to file if save path is provided.
390        if save_path != "":
391            file_path = self._save_table(self.save_path.text(), table_data)
392            show_info(f"INFO: Added table and saved file to {file_path}.")
393
394    def _add_or_update_layer(self, add_fn, name, data, scale, translate, layer_kwargs):
395        """Add a layer via ``add_fn`` (e.g. ``self.viewer.add_labels``), or refresh it in place if a
396        layer with this name already exists.
397
398        On refresh the ``scale``/``translate`` are reapplied (when provided) because a persisted layer
399        keeps its original transform, which may be stale if the source layer / voxel size changed
400        between runs; any ``layer_kwargs`` (e.g. ``opacity``, ``blending``, ``colormap``) are reapplied
401        too. On first add these go through the layer constructor. Returns the (new or existing) layer.
402        """
403        if name in self.viewer.layers:
404            layer = self.viewer.layers[name]
405            layer.data = data
406            if scale is not None:
407                layer.scale = scale
408            if translate is not None:
409                layer.translate = translate
410            for key, value in layer_kwargs.items():
411                setattr(layer, key, value)
412        else:
413            ctor_kwargs = dict(layer_kwargs)
414            if scale is not None:
415                ctor_kwargs["scale"] = scale
416            if translate is not None:
417                ctor_kwargs["translate"] = translate
418            layer = add_fn(data, name=name, **ctor_kwargs)
419        return layer
420
421    def add_or_update_labels(self, name, data, *, scale=None, translate=None, **layer_kwargs):
422        """Add a Labels layer, or refresh it in place if one with this name already exists.
423
424        See :meth:`_add_or_update_layer` for the refresh/transform semantics. Extra keyword arguments
425        (``opacity``, ``blending``, ``colormap``, …) are forwarded to the layer. Returns the layer.
426        """
427        return self._add_or_update_layer(
428            self.viewer.add_labels, name, data, scale, translate, layer_kwargs
429        )
430
431    def add_or_update_surface(self, name, vertices, faces, *, scale=None, translate=None,
432                              values=None, **layer_kwargs):
433        """Add a Surface layer, or refresh it in place if one with this name already exists.
434
435        Surface layers colour by per-vertex ``values``; a constant array (the default) gives a
436        flat-coloured surface. See :meth:`_add_or_update_layer` for the refresh/transform semantics.
437        Returns the layer.
438        """
439        if values is None:
440            values = np.ones(len(vertices), dtype="float32")
441        return self._add_or_update_layer(
442            self.viewer.add_surface, name, (vertices, faces, values), scale, translate, layer_kwargs
443        )

QWidget(parent: Optional[QWidget] = None, flags: Union[Qt.WindowFlags, Qt.WindowType] = Qt.WindowFlags())

viewer
attribute_dict
def add_or_update_labels(self, name, data, *, scale=None, translate=None, **layer_kwargs):
421    def add_or_update_labels(self, name, data, *, scale=None, translate=None, **layer_kwargs):
422        """Add a Labels layer, or refresh it in place if one with this name already exists.
423
424        See :meth:`_add_or_update_layer` for the refresh/transform semantics. Extra keyword arguments
425        (``opacity``, ``blending``, ``colormap``, …) are forwarded to the layer. Returns the layer.
426        """
427        return self._add_or_update_layer(
428            self.viewer.add_labels, name, data, scale, translate, layer_kwargs
429        )

Add a Labels layer, or refresh it in place if one with this name already exists.

See _add_or_update_layer() for the refresh/transform semantics. Extra keyword arguments (opacity, blending, colormap, …) are forwarded to the layer. Returns the layer.

def add_or_update_surface( self, name, vertices, faces, *, scale=None, translate=None, values=None, **layer_kwargs):
431    def add_or_update_surface(self, name, vertices, faces, *, scale=None, translate=None,
432                              values=None, **layer_kwargs):
433        """Add a Surface layer, or refresh it in place if one with this name already exists.
434
435        Surface layers colour by per-vertex ``values``; a constant array (the default) gives a
436        flat-coloured surface. See :meth:`_add_or_update_layer` for the refresh/transform semantics.
437        Returns the layer.
438        """
439        if values is None:
440            values = np.ones(len(vertices), dtype="float32")
441        return self._add_or_update_layer(
442            self.viewer.add_surface, name, (vertices, faces, values), scale, translate, layer_kwargs
443        )

Add a Surface layer, or refresh it in place if one with this name already exists.

Surface layers colour by per-vertex values; a constant array (the default) gives a flat-coloured surface. See _add_or_update_layer() for the refresh/transform semantics. Returns the layer.