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        A layer of a *different* type carrying this name is replaced rather than refreshed: assigning
403        e.g. an (n, 2, ndim) vectors array to a Points layer left over from an earlier run would raise
404        deep inside napari.
405        """
406        if name in self.viewer.layers:
407            expected = f"add_{type(self.viewer.layers[name]).__name__.lower()}"
408            if getattr(add_fn, "__name__", expected) != expected:
409                del self.viewer.layers[name]
410        if name in self.viewer.layers:
411            layer = self.viewer.layers[name]
412            layer.data = data
413            if scale is not None:
414                layer.scale = scale
415            if translate is not None:
416                layer.translate = translate
417            for key, value in layer_kwargs.items():
418                setattr(layer, key, value)
419        else:
420            ctor_kwargs = dict(layer_kwargs)
421            if scale is not None:
422                ctor_kwargs["scale"] = scale
423            if translate is not None:
424                ctor_kwargs["translate"] = translate
425            layer = add_fn(data, name=name, **ctor_kwargs)
426        return layer
427
428    def add_or_update_labels(self, name, data, *, scale=None, translate=None, **layer_kwargs):
429        """Add a Labels layer, or refresh it in place if one with this name already exists.
430
431        See :meth:`_add_or_update_layer` for the refresh/transform semantics. Extra keyword arguments
432        (``opacity``, ``blending``, ``colormap``, …) are forwarded to the layer. Returns the layer.
433        """
434        return self._add_or_update_layer(
435            self.viewer.add_labels, name, data, scale, translate, layer_kwargs
436        )
437
438    def add_or_update_surface(self, name, vertices, faces, *, scale=None, translate=None,
439                              values=None, **layer_kwargs):
440        """Add a Surface layer, or refresh it in place if one with this name already exists.
441
442        Surface layers colour by per-vertex ``values``; a constant array (the default) gives a
443        flat-coloured surface. See :meth:`_add_or_update_layer` for the refresh/transform semantics.
444        Returns the layer.
445        """
446        if values is None:
447            values = np.ones(len(vertices), dtype="float32")
448        return self._add_or_update_layer(
449            self.viewer.add_surface, name, (vertices, faces, values), scale, translate, layer_kwargs
450        )
451
452    def add_or_update_points(self, name, points, *, scale=None, translate=None, **layer_kwargs):
453        """Add a Points layer, or refresh it in place if one with this name already exists.
454
455        ``points`` is an (n, ndim) array in the same coordinate frame as the other layers — i.e. voxel
456        indices, with the physical placement left to ``scale``/``translate``. See
457        :meth:`_add_or_update_layer` for the refresh/transform semantics. Returns the layer.
458
459        For line-like data such as a skeleton, use :meth:`add_or_update_vectors` rather than Shapes: a
460        Shapes layer needs ``shape_type``, which is a constructor-only argument and so cannot be
461        reapplied on the refresh path, and thousands of individual line shapes render slowly.
462        """
463        return self._add_or_update_layer(
464            self.viewer.add_points, name, points, scale, translate, layer_kwargs
465        )
466
467    def add_or_update_vectors(self, name, vectors, *, scale=None, translate=None, **layer_kwargs):
468        """Add a Vectors layer, or refresh it in place if one with this name already exists.
469
470        ``vectors`` is an (n, 2, ndim) array of ``[start, direction]`` pairs, in the same coordinate
471        frame as the other layers — i.e. voxel indices, with the physical placement left to
472        ``scale``/``translate``. This is the layer to use for the edges of a graph such as a skeleton:
473        one instanced segment per edge, no constructor-only ``shape_type`` to reapply, and it stays
474        responsive at edge counts where a Shapes layer does not. See :meth:`_add_or_update_layer` for
475        the refresh/transform semantics. Returns the layer.
476        """
477        return self._add_or_update_layer(
478            self.viewer.add_vectors, name, vectors, scale, translate, layer_kwargs
479        )
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        A layer of a *different* type carrying this name is replaced rather than refreshed: assigning
404        e.g. an (n, 2, ndim) vectors array to a Points layer left over from an earlier run would raise
405        deep inside napari.
406        """
407        if name in self.viewer.layers:
408            expected = f"add_{type(self.viewer.layers[name]).__name__.lower()}"
409            if getattr(add_fn, "__name__", expected) != expected:
410                del self.viewer.layers[name]
411        if name in self.viewer.layers:
412            layer = self.viewer.layers[name]
413            layer.data = data
414            if scale is not None:
415                layer.scale = scale
416            if translate is not None:
417                layer.translate = translate
418            for key, value in layer_kwargs.items():
419                setattr(layer, key, value)
420        else:
421            ctor_kwargs = dict(layer_kwargs)
422            if scale is not None:
423                ctor_kwargs["scale"] = scale
424            if translate is not None:
425                ctor_kwargs["translate"] = translate
426            layer = add_fn(data, name=name, **ctor_kwargs)
427        return layer
428
429    def add_or_update_labels(self, name, data, *, scale=None, translate=None, **layer_kwargs):
430        """Add a Labels layer, or refresh it in place if one with this name already exists.
431
432        See :meth:`_add_or_update_layer` for the refresh/transform semantics. Extra keyword arguments
433        (``opacity``, ``blending``, ``colormap``, …) are forwarded to the layer. Returns the layer.
434        """
435        return self._add_or_update_layer(
436            self.viewer.add_labels, name, data, scale, translate, layer_kwargs
437        )
438
439    def add_or_update_surface(self, name, vertices, faces, *, scale=None, translate=None,
440                              values=None, **layer_kwargs):
441        """Add a Surface layer, or refresh it in place if one with this name already exists.
442
443        Surface layers colour by per-vertex ``values``; a constant array (the default) gives a
444        flat-coloured surface. See :meth:`_add_or_update_layer` for the refresh/transform semantics.
445        Returns the layer.
446        """
447        if values is None:
448            values = np.ones(len(vertices), dtype="float32")
449        return self._add_or_update_layer(
450            self.viewer.add_surface, name, (vertices, faces, values), scale, translate, layer_kwargs
451        )
452
453    def add_or_update_points(self, name, points, *, scale=None, translate=None, **layer_kwargs):
454        """Add a Points layer, or refresh it in place if one with this name already exists.
455
456        ``points`` is an (n, ndim) array in the same coordinate frame as the other layers — i.e. voxel
457        indices, with the physical placement left to ``scale``/``translate``. See
458        :meth:`_add_or_update_layer` for the refresh/transform semantics. Returns the layer.
459
460        For line-like data such as a skeleton, use :meth:`add_or_update_vectors` rather than Shapes: a
461        Shapes layer needs ``shape_type``, which is a constructor-only argument and so cannot be
462        reapplied on the refresh path, and thousands of individual line shapes render slowly.
463        """
464        return self._add_or_update_layer(
465            self.viewer.add_points, name, points, scale, translate, layer_kwargs
466        )
467
468    def add_or_update_vectors(self, name, vectors, *, scale=None, translate=None, **layer_kwargs):
469        """Add a Vectors layer, or refresh it in place if one with this name already exists.
470
471        ``vectors`` is an (n, 2, ndim) array of ``[start, direction]`` pairs, in the same coordinate
472        frame as the other layers — i.e. voxel indices, with the physical placement left to
473        ``scale``/``translate``. This is the layer to use for the edges of a graph such as a skeleton:
474        one instanced segment per edge, no constructor-only ``shape_type`` to reapply, and it stays
475        responsive at edge counts where a Shapes layer does not. See :meth:`_add_or_update_layer` for
476        the refresh/transform semantics. Returns the layer.
477        """
478        return self._add_or_update_layer(
479            self.viewer.add_vectors, name, vectors, scale, translate, layer_kwargs
480        )

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):
429    def add_or_update_labels(self, name, data, *, scale=None, translate=None, **layer_kwargs):
430        """Add a Labels layer, or refresh it in place if one with this name already exists.
431
432        See :meth:`_add_or_update_layer` for the refresh/transform semantics. Extra keyword arguments
433        (``opacity``, ``blending``, ``colormap``, …) are forwarded to the layer. Returns the layer.
434        """
435        return self._add_or_update_layer(
436            self.viewer.add_labels, name, data, scale, translate, layer_kwargs
437        )

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):
439    def add_or_update_surface(self, name, vertices, faces, *, scale=None, translate=None,
440                              values=None, **layer_kwargs):
441        """Add a Surface layer, or refresh it in place if one with this name already exists.
442
443        Surface layers colour by per-vertex ``values``; a constant array (the default) gives a
444        flat-coloured surface. See :meth:`_add_or_update_layer` for the refresh/transform semantics.
445        Returns the layer.
446        """
447        if values is None:
448            values = np.ones(len(vertices), dtype="float32")
449        return self._add_or_update_layer(
450            self.viewer.add_surface, name, (vertices, faces, values), scale, translate, layer_kwargs
451        )

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.

def add_or_update_points(self, name, points, *, scale=None, translate=None, **layer_kwargs):
453    def add_or_update_points(self, name, points, *, scale=None, translate=None, **layer_kwargs):
454        """Add a Points layer, or refresh it in place if one with this name already exists.
455
456        ``points`` is an (n, ndim) array in the same coordinate frame as the other layers — i.e. voxel
457        indices, with the physical placement left to ``scale``/``translate``. See
458        :meth:`_add_or_update_layer` for the refresh/transform semantics. Returns the layer.
459
460        For line-like data such as a skeleton, use :meth:`add_or_update_vectors` rather than Shapes: a
461        Shapes layer needs ``shape_type``, which is a constructor-only argument and so cannot be
462        reapplied on the refresh path, and thousands of individual line shapes render slowly.
463        """
464        return self._add_or_update_layer(
465            self.viewer.add_points, name, points, scale, translate, layer_kwargs
466        )

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

points is an (n, ndim) array in the same coordinate frame as the other layers — i.e. voxel indices, with the physical placement left to scale/translate. See _add_or_update_layer() for the refresh/transform semantics. Returns the layer.

For line-like data such as a skeleton, use add_or_update_vectors() rather than Shapes: a Shapes layer needs shape_type, which is a constructor-only argument and so cannot be reapplied on the refresh path, and thousands of individual line shapes render slowly.

def add_or_update_vectors(self, name, vectors, *, scale=None, translate=None, **layer_kwargs):
468    def add_or_update_vectors(self, name, vectors, *, scale=None, translate=None, **layer_kwargs):
469        """Add a Vectors layer, or refresh it in place if one with this name already exists.
470
471        ``vectors`` is an (n, 2, ndim) array of ``[start, direction]`` pairs, in the same coordinate
472        frame as the other layers — i.e. voxel indices, with the physical placement left to
473        ``scale``/``translate``. This is the layer to use for the edges of a graph such as a skeleton:
474        one instanced segment per edge, no constructor-only ``shape_type`` to reapply, and it stays
475        responsive at edge counts where a Shapes layer does not. See :meth:`_add_or_update_layer` for
476        the refresh/transform semantics. Returns the layer.
477        """
478        return self._add_or_update_layer(
479            self.viewer.add_vectors, name, vectors, scale, translate, layer_kwargs
480        )

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

vectors is an (n, 2, ndim) array of [start, direction] pairs, in the same coordinate frame as the other layers — i.e. voxel indices, with the physical placement left to scale/translate. This is the layer to use for the edges of a graph such as a skeleton: one instanced segment per edge, no constructor-only shape_type to reapply, and it stays responsive at edge counts where a Shapes layer does not. See _add_or_update_layer() for the refresh/transform semantics. Returns the layer.