Beispiel #1
0
    def __init__(self, widget: LaserWidget):
        super().__init__(widget, graphics_label="Preview")

        self.graphics = LaserGraphicsView(self.viewspace.options, parent=self)
        self.graphics.cursorValueChanged.connect(self.widget.updateCursorStatus)
        self.graphics.setMouseTracking(True)

        self.output = QtWidgets.QLineEdit("Result")
        self.output.setEnabled(False)

        self.lineedit_name = CalculatorName(
            "",
            badnames=[],
            badparser=list(CalculatorTool.functions.keys()),
        )
        self.lineedit_name.revalidate()
        self.lineedit_name.textEdited.connect(self.completeChanged)
        self.lineedit_name.editingFinished.connect(self.refresh)

        self.combo_element = QtWidgets.QComboBox()
        self.combo_element.activated.connect(self.insertVariable)

        functions = [k + v[0][1] for k, v in CalculatorTool.functions.items()]
        tooltips = [v[0][2] for v in CalculatorTool.functions.values()]
        self.combo_function = QtWidgets.QComboBox()
        self.combo_function.addItem("Functions")
        self.combo_function.addItems(functions)
        for i in range(0, len(tooltips)):
            self.combo_function.setItemData(i + 1, tooltips[i], QtCore.Qt.ToolTipRole)
        self.combo_function.activated.connect(self.insertFunction)

        self.reducer = Reducer({})
        self.formula = CalculatorFormula("", variables=[])
        self.formula.textChanged.connect(self.completeChanged)
        self.formula.textChanged.connect(self.refresh)

        self.reducer.operations.update(
            {k: v[1] for k, v in CalculatorTool.functions.items()}
        )
        self.formula.parser.nulls.update(
            {k: v[0][0] for k, v in CalculatorTool.functions.items()}
        )

        layout_combos = QtWidgets.QHBoxLayout()
        layout_combos.addWidget(self.combo_element)
        layout_combos.addWidget(self.combo_function)

        layout_graphics = QtWidgets.QVBoxLayout()
        layout_graphics.addWidget(self.graphics)
        self.box_graphics.setLayout(layout_graphics)

        layout_controls = QtWidgets.QFormLayout()
        layout_controls.addRow("Name:", self.lineedit_name)
        layout_controls.addRow("Insert:", layout_combos)
        layout_controls.addRow("Formula:", self.formula)
        layout_controls.addRow("Result:", self.output)
        self.box_controls.setLayout(layout_controls)

        self.initialise()  # refreshes
Beispiel #2
0
def test_selection_dialog(qtbot: QtBot):
    x = np.random.random((10, 10))
    graphics = LaserGraphicsView(GraphicsOptions())
    graphics.drawImage(x, QtCore.QRectF(0, 0, 10, 10), "x")

    dialog = dialogs.SelectionDialog(graphics)
    qtbot.addWidget(dialog)
    dialog.open()

    # Test enabling of options
    assert dialog.lineedit_manual.isEnabled()
    assert not dialog.spinbox_method.isEnabled()
    assert not dialog.spinbox_comparison.isEnabled()

    dialog.combo_method.setCurrentText("K-means")
    dialog.refresh()
    assert not dialog.lineedit_manual.isEnabled()
    assert dialog.spinbox_method.isEnabled()
    assert dialog.spinbox_comparison.isEnabled()
    assert dialog.spinbox_method.value() == 3
    assert dialog.spinbox_comparison.value() == 1

    dialog.combo_method.setCurrentText("Mean")
    dialog.check_limit_selection.setChecked(True)
    dialog.refresh()
    assert not dialog.spinbox_method.isEnabled()
    assert not dialog.spinbox_comparison.isEnabled()

    # Test correct states and masks emmited
    with qtbot.wait_signal(dialog.maskSelected) as emitted:
        dialog.apply()
    assert np.all(emitted.args[0] == (x > x.mean()))
    assert emitted.args[1] == ["intersect"]

    dialog.check_limit_selection.setChecked(False)
    dialog.combo_method.setCurrentText("Manual")
    dialog.lineedit_manual.setText("0.9")
    dialog.refresh()

    with qtbot.wait_signal(dialog.maskSelected) as emitted:
        dialog.apply()
    assert np.all(emitted.args[0] == (x > 0.9))
    assert emitted.args[1] == [""]

    dialog.graphics.selection = emitted.args[0]

    # Test limit threshold
    dialog.combo_method.setCurrentText("Mean")
    dialog.check_limit_threshold.setChecked(True)
    graphics.mask = x > 0.9
    dialog.refresh()

    with qtbot.wait_signal(dialog.maskSelected) as emitted:
        dialog.apply()
    assert np.all(emitted.args[0] == (x > np.mean(x[x > 0.9])))
    assert emitted.args[1] == [""]
Beispiel #3
0
    def __init__(self, widget: LaserWidget):
        super().__init__(widget, graphics_label="Preview")

        self.graphics = LaserGraphicsView(self.viewspace.options, parent=self)
        self.graphics.cursorValueChanged.connect(
            self.widget.updateCursorStatus)
        self.graphics.setMouseTracking(True)

        self.action_toggle_filter = qAction(
            "visibility",
            "Filter Visible",
            "Toggle visibility of the filtering.",
            self.toggleFilter,
        )
        self.action_toggle_filter.setCheckable(True)
        self.button_hide_filter = qToolButton(action=self.action_toggle_filter)
        self.button_hide_filter.setToolButtonStyle(
            QtCore.Qt.ToolButtonTextBesideIcon)

        self.combo_element = QtWidgets.QComboBox()
        self.combo_element.activated.connect(self.completeChanged)
        self.combo_element.activated.connect(self.refresh)

        self.combo_filter = QtWidgets.QComboBox()
        self.combo_filter.addItems(FilteringTool.methods.keys())
        self.combo_filter.setCurrentText("Rolling Median")
        self.combo_filter.activated.connect(self.filterChanged)
        self.combo_filter.activated.connect(self.completeChanged)
        self.combo_filter.activated.connect(self.refresh)

        nparams = np.amax(
            [len(f["params"]) for f in FilteringTool.methods.values()])
        self.label_fparams = [QtWidgets.QLabel() for _ in range(nparams)]
        self.lineedit_fparams = [ValidColorLineEdit() for _ in range(nparams)]
        for le in self.lineedit_fparams:
            le.textEdited.connect(self.completeChanged)
            le.editingFinished.connect(self.refresh)
            le.setValidator(
                ConditionalLimitValidator(0.0, 0.0, 4, condition=None))

        layout_graphics = QtWidgets.QVBoxLayout()
        layout_graphics.addWidget(self.graphics)
        layout_graphics.addWidget(self.combo_element, 0, QtCore.Qt.AlignRight)
        self.box_graphics.setLayout(layout_graphics)

        layout_controls = QtWidgets.QFormLayout()
        layout_controls.addWidget(self.combo_filter)
        for i in range(len(self.label_fparams)):
            layout_controls.addRow(self.label_fparams[i],
                                   self.lineedit_fparams[i])
        layout_controls.addWidget(self.button_hide_filter)

        self.box_controls.setLayout(layout_controls)

        self.initialise()
Beispiel #4
0
    def __init__(self, laser: Laser, options: GraphicsOptions, view: LaserView = None):
        super().__init__(view)
        self.laser = laser
        self.is_srr = isinstance(laser, SRRLaser)

        self.graphics = LaserGraphicsView(options, parent=self)
        self.graphics.setMouseTracking(True)

        self.graphics.cursorValueChanged.connect(self.updateCursorStatus)
        self.graphics.label.labelChanged.connect(self.renameCurrentElement)
        self.graphics.colorbar.editRequested.connect(self.actionRequestColorbarEdit)

        self.combo_layers = QtWidgets.QComboBox()
        self.combo_layers.addItem("*")
        self.combo_layers.addItems([str(i) for i in range(0, self.laser.layers)])
        self.combo_layers.currentIndexChanged.connect(self.refresh)
        if not self.is_srr:
            self.combo_layers.setEnabled(False)
            self.combo_layers.setVisible(False)

        self.combo_element = LaserComboBox()
        self.combo_element.namesSelected.connect(self.updateNames)
        self.combo_element.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContents)
        self.combo_element.currentIndexChanged.connect(self.refresh)
        self.populateElements()

        self.action_calibration = qAction(
            "go-top",
            "Ca&libration",
            "Edit the documents calibration.",
            self.actionCalibration,
        )
        self.action_config = qAction(
            "document-edit", "&Config", "Edit the document's config.", self.actionConfig
        )
        self.action_copy_image = qAction(
            "insert-image",
            "Copy &Image",
            "Copy image to clipboard.",
            self.actionCopyImage,
        )
        self.action_duplicate = qAction(
            "edit-copy",
            "Duplicate image",
            "Open a copy of the image.",
            self.actionDuplicate,
        )
        self.action_export = qAction(
            "document-save-as", "E&xport", "Export documents.", self.actionExport
        )
        self.action_export.setShortcut("Ctrl+X")
        # Add the export action so we can use it via shortcut
        self.addAction(self.action_export)
        self.action_information = qAction(
            "documentinfo",
            "In&formation",
            "View and edit image information.",
            self.actionInformation,
        )
        self.action_save = qAction(
            "document-save", "&Save", "Save document to numpy archive.", self.actionSave
        )
        self.action_save.setShortcut("Ctrl+S")
        # Add the save action so we can use it via shortcut
        self.addAction(self.action_save)
        self.action_statistics = qAction(
            "dialog-information",
            "Statistics",
            "Open the statisitics dialog.",
            self.actionStatistics,
        )
        self.action_select_statistics = qAction(
            "dialog-information",
            "Selection Statistics",
            "Open the statisitics dialog for the current selection.",
            self.actionStatisticsSelection,
        )
        self.action_colocalisation = qAction(
            "dialog-information",
            "Colocalisation",
            "Open the colocalisation dialog.",
            self.actionColocal,
        )
        self.action_select_colocalisation = qAction(
            "dialog-information",
            "Selection Colocalisation",
            "Open the colocalisation dialog for the current selection.",
            self.actionColocalSelection,
        )

        # Toolbar actions
        self.action_select_none = qAction(
            "transform-move",
            "Clear Selection",
            "Clear any selections.",
            self.graphics.endSelection,
        )
        self.action_widget_none = qAction(
            "transform-move",
            "Clear Widgets",
            "Closes any open widgets.",
            self.graphics.endWidget,
        )
        self.action_select_rect = qAction(
            "draw-rectangle",
            "Rectangle Selector",
            "Start the rectangle selector tool, use 'Shift' "
            "to add to selection and 'Control' to subtract.",
            self.graphics.startRectangleSelection,
        )
        self.action_select_lasso = qAction(
            "draw-freehand",
            "Lasso Selector",
            "Start the lasso selector tool, use 'Shift' "
            "to add to selection and 'Control' to subtract.",
            self.graphics.startLassoSelection,
        )
        self.action_select_dialog = qAction(
            "dialog-information",
            "Selection Dialog",
            "Start the selection dialog.",
            self.actionSelectDialog,
        )
        self.action_select_copy_text = qAction(
            "insert-table",
            "Copy Selection as Text",
            "Copy the current selection to the clipboard as a column of text values.",
            self.actionCopySelectionText,
        )
        self.action_select_crop = qAction(
            "transform-crop",
            "Crop to Selection",
            "Crop the image to the current selection.",
            self.actionCropSelection,
        )

        self.selection_button = qToolButton("select", "Selection")
        self.selection_button.addAction(self.action_select_none)
        self.selection_button.addAction(self.action_select_rect)
        self.selection_button.addAction(self.action_select_lasso)
        self.selection_button.addAction(self.action_select_dialog)

        self.action_ruler = qAction(
            "tool-measure",
            "Measure",
            "Use a ruler to measure distance.",
            self.graphics.startRulerWidget,
        )
        self.action_slice = qAction(
            "tool-measure",
            "1D Slice",
            "Select and display a 1-dimensional slice of the image.",
            self.graphics.startSliceWidget,
        )
        self.widgets_button = qToolButton("tool-measure", "Widgets")
        self.widgets_button.addAction(self.action_widget_none)
        self.widgets_button.addAction(self.action_ruler)
        self.widgets_button.addAction(self.action_slice)

        self.action_zoom_in = qAction(
            "zoom-in",
            "Zoom to Area",
            "Start zoom area selection.",
            self.graphics.zoomStart,
        )
        self.action_zoom_out = qAction(
            "zoom-original",
            "Reset Zoom",
            "Reset zoom to full image extent.",
            self.graphics.zoomReset,
        )
        self.view_button = qToolButton("zoom", "Zoom")
        self.view_button.addAction(self.action_zoom_in)
        self.view_button.addAction(self.action_zoom_out)

        self.graphics.viewport().installEventFilter(DragDropRedirectFilter(self))
        # Filters for setting active view
        self.graphics.viewport().installEventFilter(self)
        self.combo_element.installEventFilter(self)
        self.selection_button.installEventFilter(self)
        self.widgets_button.installEventFilter(self)
        self.view_button.installEventFilter(self)

        layout_bar = QtWidgets.QHBoxLayout()
        layout_bar.addWidget(self.selection_button, 0, QtCore.Qt.AlignLeft)
        layout_bar.addWidget(self.widgets_button, 0, QtCore.Qt.AlignLeft)
        layout_bar.addWidget(self.view_button, 0, QtCore.Qt.AlignLeft)
        layout_bar.addStretch(1)
        layout_bar.addWidget(self.combo_layers, 0, QtCore.Qt.AlignRight)
        layout_bar.addWidget(self.combo_element, 0, QtCore.Qt.AlignRight)

        layout = QtWidgets.QVBoxLayout()
        layout.addWidget(self.graphics, 1)
        layout.addLayout(layout_bar)
        self.setLayout(layout)
Beispiel #5
0
class LaserWidget(_ViewWidget):
    """Class that stores and displays a laser image.

    Tracks modification of the data, config, calibration and information.
    Create via `:func:pewpew.laser.LaserView.addLaser`.

    Args:
        laser: input
        options: graphics options for this widget
        view: parent view
    """

    def __init__(self, laser: Laser, options: GraphicsOptions, view: LaserView = None):
        super().__init__(view)
        self.laser = laser
        self.is_srr = isinstance(laser, SRRLaser)

        self.graphics = LaserGraphicsView(options, parent=self)
        self.graphics.setMouseTracking(True)

        self.graphics.cursorValueChanged.connect(self.updateCursorStatus)
        self.graphics.label.labelChanged.connect(self.renameCurrentElement)
        self.graphics.colorbar.editRequested.connect(self.actionRequestColorbarEdit)

        self.combo_layers = QtWidgets.QComboBox()
        self.combo_layers.addItem("*")
        self.combo_layers.addItems([str(i) for i in range(0, self.laser.layers)])
        self.combo_layers.currentIndexChanged.connect(self.refresh)
        if not self.is_srr:
            self.combo_layers.setEnabled(False)
            self.combo_layers.setVisible(False)

        self.combo_element = LaserComboBox()
        self.combo_element.namesSelected.connect(self.updateNames)
        self.combo_element.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContents)
        self.combo_element.currentIndexChanged.connect(self.refresh)
        self.populateElements()

        self.action_calibration = qAction(
            "go-top",
            "Ca&libration",
            "Edit the documents calibration.",
            self.actionCalibration,
        )
        self.action_config = qAction(
            "document-edit", "&Config", "Edit the document's config.", self.actionConfig
        )
        self.action_copy_image = qAction(
            "insert-image",
            "Copy &Image",
            "Copy image to clipboard.",
            self.actionCopyImage,
        )
        self.action_duplicate = qAction(
            "edit-copy",
            "Duplicate image",
            "Open a copy of the image.",
            self.actionDuplicate,
        )
        self.action_export = qAction(
            "document-save-as", "E&xport", "Export documents.", self.actionExport
        )
        self.action_export.setShortcut("Ctrl+X")
        # Add the export action so we can use it via shortcut
        self.addAction(self.action_export)
        self.action_information = qAction(
            "documentinfo",
            "In&formation",
            "View and edit image information.",
            self.actionInformation,
        )
        self.action_save = qAction(
            "document-save", "&Save", "Save document to numpy archive.", self.actionSave
        )
        self.action_save.setShortcut("Ctrl+S")
        # Add the save action so we can use it via shortcut
        self.addAction(self.action_save)
        self.action_statistics = qAction(
            "dialog-information",
            "Statistics",
            "Open the statisitics dialog.",
            self.actionStatistics,
        )
        self.action_select_statistics = qAction(
            "dialog-information",
            "Selection Statistics",
            "Open the statisitics dialog for the current selection.",
            self.actionStatisticsSelection,
        )
        self.action_colocalisation = qAction(
            "dialog-information",
            "Colocalisation",
            "Open the colocalisation dialog.",
            self.actionColocal,
        )
        self.action_select_colocalisation = qAction(
            "dialog-information",
            "Selection Colocalisation",
            "Open the colocalisation dialog for the current selection.",
            self.actionColocalSelection,
        )

        # Toolbar actions
        self.action_select_none = qAction(
            "transform-move",
            "Clear Selection",
            "Clear any selections.",
            self.graphics.endSelection,
        )
        self.action_widget_none = qAction(
            "transform-move",
            "Clear Widgets",
            "Closes any open widgets.",
            self.graphics.endWidget,
        )
        self.action_select_rect = qAction(
            "draw-rectangle",
            "Rectangle Selector",
            "Start the rectangle selector tool, use 'Shift' "
            "to add to selection and 'Control' to subtract.",
            self.graphics.startRectangleSelection,
        )
        self.action_select_lasso = qAction(
            "draw-freehand",
            "Lasso Selector",
            "Start the lasso selector tool, use 'Shift' "
            "to add to selection and 'Control' to subtract.",
            self.graphics.startLassoSelection,
        )
        self.action_select_dialog = qAction(
            "dialog-information",
            "Selection Dialog",
            "Start the selection dialog.",
            self.actionSelectDialog,
        )
        self.action_select_copy_text = qAction(
            "insert-table",
            "Copy Selection as Text",
            "Copy the current selection to the clipboard as a column of text values.",
            self.actionCopySelectionText,
        )
        self.action_select_crop = qAction(
            "transform-crop",
            "Crop to Selection",
            "Crop the image to the current selection.",
            self.actionCropSelection,
        )

        self.selection_button = qToolButton("select", "Selection")
        self.selection_button.addAction(self.action_select_none)
        self.selection_button.addAction(self.action_select_rect)
        self.selection_button.addAction(self.action_select_lasso)
        self.selection_button.addAction(self.action_select_dialog)

        self.action_ruler = qAction(
            "tool-measure",
            "Measure",
            "Use a ruler to measure distance.",
            self.graphics.startRulerWidget,
        )
        self.action_slice = qAction(
            "tool-measure",
            "1D Slice",
            "Select and display a 1-dimensional slice of the image.",
            self.graphics.startSliceWidget,
        )
        self.widgets_button = qToolButton("tool-measure", "Widgets")
        self.widgets_button.addAction(self.action_widget_none)
        self.widgets_button.addAction(self.action_ruler)
        self.widgets_button.addAction(self.action_slice)

        self.action_zoom_in = qAction(
            "zoom-in",
            "Zoom to Area",
            "Start zoom area selection.",
            self.graphics.zoomStart,
        )
        self.action_zoom_out = qAction(
            "zoom-original",
            "Reset Zoom",
            "Reset zoom to full image extent.",
            self.graphics.zoomReset,
        )
        self.view_button = qToolButton("zoom", "Zoom")
        self.view_button.addAction(self.action_zoom_in)
        self.view_button.addAction(self.action_zoom_out)

        self.graphics.viewport().installEventFilter(DragDropRedirectFilter(self))
        # Filters for setting active view
        self.graphics.viewport().installEventFilter(self)
        self.combo_element.installEventFilter(self)
        self.selection_button.installEventFilter(self)
        self.widgets_button.installEventFilter(self)
        self.view_button.installEventFilter(self)

        layout_bar = QtWidgets.QHBoxLayout()
        layout_bar.addWidget(self.selection_button, 0, QtCore.Qt.AlignLeft)
        layout_bar.addWidget(self.widgets_button, 0, QtCore.Qt.AlignLeft)
        layout_bar.addWidget(self.view_button, 0, QtCore.Qt.AlignLeft)
        layout_bar.addStretch(1)
        layout_bar.addWidget(self.combo_layers, 0, QtCore.Qt.AlignRight)
        layout_bar.addWidget(self.combo_element, 0, QtCore.Qt.AlignRight)

        layout = QtWidgets.QVBoxLayout()
        layout.addWidget(self.graphics, 1)
        layout.addLayout(layout_bar)
        self.setLayout(layout)

    @property
    def current_element(self) -> str:
        return self.combo_element.currentText()

    @current_element.setter
    def current_element(self, element: str) -> None:
        self.combo_element.setCurrentText(element)

    @property
    def current_layer(self) -> Optional[int]:
        if not self.is_srr or self.combo_layers.currentIndex() == 0:
            return None
        return int(self.combo_layers.currentText())

    # Virtual
    def refresh(self) -> None:
        """Redraw image."""
        self.graphics.drawLaser(
            self.laser, self.current_element, layer=self.current_layer
        )
        if self.graphics.widget is not None:
            self.graphics.widget.imageChanged(self.graphics.image, self.graphics.data)
        self.graphics.invalidateScene()
        super().refresh()

    def rename(self, text: str) -> None:
        """Set the 'Name' value of laser information."""
        self.laser.info["Name"] = text
        self.modified = True

    # Other

    def renameCurrentElement(self, new: str) -> None:
        """Rename a single element."""
        self.laser.rename({self.current_element: new})
        self.modified = True
        self.populateElements()
        self.current_element = new
        self.refresh()

    def laserName(self) -> str:
        return self.laser.info.get("Name", "<No Name>")

    def laserFilePath(self, ext: str = ".npz") -> Path:
        path = Path(self.laser.info.get("File Path", ""))
        return path.with_name(self.laserName() + ext)

    def populateElements(self) -> None:
        """Repopulate the element combo box."""
        self.combo_element.blockSignals(True)
        self.combo_element.clear()
        self.combo_element.addItems(self.laser.elements)
        self.combo_element.blockSignals(False)

    def clearCursorStatus(self) -> None:
        """Clear window statusbar, if it exists."""
        status_bar = self.viewspace.window().statusBar()
        if status_bar is not None:
            status_bar.clearMessage()

    def updateCursorStatus(self, x: float, y: float, v: float) -> None:
        """Updates the windows statusbar if it exists."""
        status_bar = self.viewspace.window().statusBar()
        if status_bar is None:  # pragma: no cover
            return

        if self.viewspace.options.units == "index":  # convert to indices
            p = self.graphics.mapToData(QtCore.QPointF(x, y))
            x, y = p.x(), p.y()

        if v is None:
            status_bar.clearMessage()
        elif np.isfinite(v):
            status_bar.showMessage(f"{x:.4g},{y:.4g} [{v:.4g}]")
        else:
            status_bar.showMessage(f"{x:.4g},{y:.4g} [nan]")

    def updateNames(self, rename: dict) -> None:
        """Rename multiple elements."""
        current = self.current_element
        self.laser.rename(rename)
        self.populateElements()
        current = rename[current]
        self.current_element = current

        self.modified = True

    # Transformations
    def cropToSelection(self) -> None:
        """Crop image to current selection and open in a new tab.

        If selection is not rectangular then it is filled with nan.
        """
        if self.is_srr:  # pragma: no cover
            QtWidgets.QMessageBox.information(
                self, "Transform", "Unable to transform SRR data."
            )
            return

        mask = self.graphics.mask
        if mask is None or np.all(mask == 0):  # pragma: no cover
            return
        ix, iy = np.nonzero(mask)
        x0, x1, y0, y1 = np.min(ix), np.max(ix) + 1, np.min(iy), np.max(iy) + 1

        data = self.laser.data
        new_data = np.empty((x1 - x0, y1 - y0), dtype=data.dtype)
        for name in new_data.dtype.names:
            new_data[name] = np.where(
                mask[x0:x1, y0:y1], data[name][x0:x1, y0:y1], np.nan
            )

        info = self.laser.info.copy()
        info["Name"] = self.laserName() + "_cropped"
        info["File Path"] = str(Path(info.get("File Path", "")).with_stem(info["Name"]))
        new_widget = self.view.addLaser(
            Laser(
                new_data,
                calibration=self.laser.calibration,
                config=self.laser.config,
                info=info,
            )
        )

        new_widget.activate()

    def transform(self, flip: str = None, rotate: str = None) -> None:
        """Transform the image.

        Args:
            flip: flip the image ['horizontal', 'vertical']
            rotate: rotate the image 90 degrees ['left', 'right']

        """
        if self.is_srr:  # pragma: no cover
            QtWidgets.QMessageBox.information(
                self, "Transform", "Unable to transform SRR data."
            )
            return
        if flip is not None:
            if flip in ["horizontal", "vertical"]:
                axis = 1 if flip == "horizontal" else 0
                self.laser.data = np.flip(self.laser.data, axis=axis)
            else:
                raise ValueError("flip must be 'horizontal', 'vertical'.")
        if rotate is not None:
            if rotate in ["left", "right"]:
                k = 1 if rotate == "right" else 3 if rotate == "left" else 2
                self.laser.data = np.rot90(self.laser.data, k=k, axes=(1, 0))
            else:
                raise ValueError("rotate must be 'left', 'right'.")
        self.modified = True
        self.refresh()

    # Callbacks
    def applyCalibration(self, calibrations: Dict[str, Calibration]) -> None:
        """Set laser calibrations."""
        modified = False
        for element in calibrations:
            if element in self.laser.calibration:
                self.laser.calibration[element] = copy.copy(calibrations[element])
                modified = True
        if modified:
            self.modified = True
            self.refresh()

    def applyConfig(self, config: Config) -> None:
        """Set laser configuration."""
        # Only apply if the type of config is correct
        if type(config) is type(self.laser.config):  # noqa
            self.laser.config = copy.copy(config)
            self.modified = True
            self.refresh()

    def applyInformation(self, info: Dict[str, str]) -> None:
        """Set laser information."""
        # if self.laser.info["Name"] != info["Name"]:  # pragma: ignore
        #     self.view.tabs.setTabText(self.index(), info["Name"])
        if self.laser.info != info:
            self.laser.info = info
            self.modified = True

    def saveDocument(self, path: Union[str, Path]) -> None:
        """Saves the laser to an '.npz' file.

        See Also:
            `:func:pewlib.io.npz.save`
        """
        if isinstance(path, str):
            path = Path(path)

        io.npz.save(path, self.laser)
        self.laser.info["File Path"] = str(path.resolve())
        self.modified = False

    def actionCalibration(self) -> QtWidgets.QDialog:
        """Open a `:class:pewpew.widgets.dialogs.CalibrationDialog` and applies result."""
        dlg = dialogs.CalibrationDialog(
            self.laser.calibration, self.current_element, parent=self
        )
        dlg.calibrationSelected.connect(self.applyCalibration)
        dlg.calibrationApplyAll.connect(self.viewspace.applyCalibration)
        dlg.open()
        return dlg

    def actionConfig(self) -> QtWidgets.QDialog:
        """Open a `:class:pewpew.widgets.dialogs.ConfigDialog` and applies result."""
        dlg = dialogs.ConfigDialog(self.laser.config, parent=self)
        dlg.configSelected.connect(self.applyConfig)
        dlg.configApplyAll.connect(self.viewspace.applyConfig)
        dlg.open()
        return dlg

    def actionCopyImage(self) -> None:
        self.graphics.copyToClipboard()

    def actionCopySelectionText(self) -> None:
        """Copies the currently selected data to the system clipboard."""
        data = self.graphics.data[self.graphics.mask].ravel()

        html = (
            '<meta http-equiv="content-type" content="text/html; charset=utf-8"/>'
            "<table>"
        )
        text = ""
        for x in data:
            html += f"<tr><td>{x:.10g}</td></tr>"
            text += f"{x:.10g}\n"
        html += "</table>"

        mime = QtCore.QMimeData()
        mime.setHtml(html)
        mime.setText(text)
        QtWidgets.QApplication.clipboard().setMimeData(mime)

    def actionCropSelection(self) -> None:
        self.cropToSelection()

    def actionDuplicate(self) -> None:
        """Duplicate document to a new tab."""
        self.view.addLaser(copy.deepcopy(self.laser))

    def actionExport(self) -> QtWidgets.QDialog:
        """Opens a `:class:pewpew.exportdialogs.ExportDialog`.

        This can save the document to various formats.
        """
        dlg = exportdialogs.ExportDialog(self, parent=self)
        dlg.open()
        return dlg

    def actionInformation(self) -> QtWidgets.QDialog:
        """Opens a `:class:pewpew.widgets.dialogs.InformationDialog`."""
        dlg = dialogs.InformationDialog(self.laser.info, parent=self)
        dlg.infoChanged.connect(self.applyInformation)
        dlg.open()
        return dlg

    def actionRequestColorbarEdit(self) -> None:
        if self.viewspace is not None:
            self.viewspace.colortableRangeDialog()

    def actionSave(self) -> QtWidgets.QDialog:
        """Save the document to an '.npz' file.

        If not already associated with an '.npz' path a dialog is opened to select one.
        """
        path = Path(self.laser.info["File Path"])
        if path.suffix.lower() == ".npz" and path.exists():
            self.saveDocument(path)
            return None
        else:
            path = self.laserFilePath()
        dlg = QtWidgets.QFileDialog(
            self, "Save File", str(path.resolve()), "Numpy archive(*.npz);;All files(*)"
        )
        dlg.setAcceptMode(QtWidgets.QFileDialog.AcceptSave)
        dlg.fileSelected.connect(self.saveDocument)
        dlg.open()
        return dlg

    def actionSelectDialog(self) -> QtWidgets.QDialog:
        """Open a `:class:pewpew.widgets.dialogs.SelectionDialog` and applies selection."""
        dlg = dialogs.SelectionDialog(self.graphics, parent=self)
        dlg.maskSelected.connect(self.graphics.drawSelectionImage)
        self.refreshed.connect(dlg.refresh)
        dlg.show()
        return dlg

    def actionStatistics(self, crop_to_selection: bool = False) -> QtWidgets.QDialog:
        """Open a `:class:pewpew.widgets.dialogs.StatsDialog` with image data.

        Args:
            crop_to_selection: pass current selection as a mask
        """
        data = self.laser.get(calibrate=self.viewspace.options.calibrate, flat=True)
        mask = self.graphics.mask
        if mask is None or not crop_to_selection:
            mask = np.ones(data.shape, dtype=bool)

        units = {}
        if self.viewspace.options.calibrate:
            units = {k: v.unit for k, v in self.laser.calibration.items()}

        dlg = dialogs.StatsDialog(
            data,
            mask,
            units,
            self.current_element,
            pixel_size=(
                self.laser.config.get_pixel_width(),
                self.laser.config.get_pixel_height(),
            ),
            parent=self,
        )
        dlg.open()
        return dlg

    def actionStatisticsSelection(self) -> QtWidgets.QDialog:
        return self.actionStatistics(True)

    def actionColocal(self, crop_to_selection: bool = False) -> QtWidgets.QDialog:
        """Open a `:class:pewpew.widgets.dialogs.ColocalisationDialog` with image data.

        Args:
            crop_to_selection: pass current selection as a mask
        """
        data = self.laser.get(flat=True)
        mask = self.graphics.mask if crop_to_selection else None

        dlg = dialogs.ColocalisationDialog(data, mask, parent=self)
        dlg.open()
        return dlg

    def actionColocalSelection(self) -> QtWidgets.QDialog:
        return self.actionColocal(True)

    # Events
    def contextMenuEvent(self, event: QtGui.QContextMenuEvent):
        menu = QtWidgets.QMenu(self)
        # menu.addAction(self.action_duplicate)
        menu.addAction(self.action_copy_image)
        menu.addSeparator()

        if self.graphics.posInSelection(event.pos()):
            menu.addAction(self.action_select_copy_text)
            menu.addAction(self.action_select_crop)
            menu.addSeparator()
            menu.addAction(self.action_select_statistics)
            menu.addAction(self.action_select_colocalisation)
        else:
            menu.addAction(self.view.action_open)
            menu.addAction(self.action_save)
            menu.addAction(self.action_export)
            menu.addSeparator()
            menu.addAction(self.action_config)
            menu.addAction(self.action_calibration)
            menu.addAction(self.action_information)
            menu.addSeparator()
            menu.addAction(self.action_statistics)
            menu.addAction(self.action_colocalisation)
        menu.popup(event.globalPos())
        event.accept()

    def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
        if event.matches(QtGui.QKeySequence.Cancel):
            self.graphics.endSelection()
            self.graphics.endWidget()
        elif event.matches(QtGui.QKeySequence.Paste):
            mime = QtWidgets.QApplication.clipboard().mimeData()
            if mime.hasFormat("arplication/x-pew2config"):
                with BytesIO(mime.data("application/x-pew2config")) as fp:
                    array = np.load(fp)
                if self.is_srr:
                    config = SRRConfig.from_array(array)
                else:
                    config = Config.from_array(array)
                self.applyConfig(config)
            elif mime.hasFormat("application/x-pew2calibration"):
                with BytesIO(mime.data("application/x-pew2calibration")) as fp:
                    npy = np.load(fp)
                    calibrations = {k: Calibration.from_array(npy[k]) for k in npy}
                self.applyCalibration(calibrations)
        super().keyPressEvent(event)

    def showEvent(self, event: QtGui.QShowEvent) -> None:
        self.refresh()
        super().showEvent(event)
Beispiel #6
0
def test_laser_graphics_selection(qtbot: QtBot):
    graphics = LaserGraphicsView(GraphicsOptions())
    qtbot.addWidget(graphics)

    x = np.random.random((10, 10))
    graphics.drawImage(x, QtCore.QRectF(0, 0, 100, 100), "x")

    qtbot.waitExposed(graphics)

    # Test rectangle selector and center pixel selecting
    graphics.startRectangleSelection()

    event = QtGui.QMouseEvent(
        QtCore.QEvent.MouseButtonPress,
        graphics.mapFromScene(QtCore.QPointF(1, 1)),
        QtCore.Qt.LeftButton,
        QtCore.Qt.LeftButton,
        QtCore.Qt.NoModifier,
    )

    graphics.mousePressEvent(event)
    event.setLocalPos(graphics.mapFromScene(QtCore.QPointF(9, 9)))
    graphics.mouseMoveEvent(event)
    graphics.mouseReleaseEvent(event)

    assert graphics.mask[0][0]
    assert np.all(graphics.mask[1:, :] == 0)
    assert np.all(graphics.mask[:, 1:] == 0)

    graphics.endSelection()
    assert graphics.selection_item is None
    assert np.all(graphics.mask == 0)
    assert not graphics.posInSelection(graphics.mapFromScene(QtCore.QPoint(1, 1)))

    # Test lasso works
    graphics.startLassoSelection()

    event = QtGui.QMouseEvent(
        QtCore.QEvent.MouseButtonPress,
        graphics.mapFromScene(QtCore.QPointF(1, 1)),
        QtCore.Qt.LeftButton,
        QtCore.Qt.LeftButton,
        QtCore.Qt.NoModifier,
    )

    graphics.mousePressEvent(event)
    event.setLocalPos(graphics.mapFromScene(QtCore.QPointF(99, 1)))
    graphics.mouseMoveEvent(event)
    event.setLocalPos(graphics.mapFromScene(QtCore.QPointF(99, 99)))
    graphics.mouseMoveEvent(event)
    event.setLocalPos(graphics.mapFromScene(QtCore.QPointF(91, 99)))
    graphics.mouseMoveEvent(event)
    event.setLocalPos(graphics.mapFromScene(QtCore.QPointF(91, 11)))
    graphics.mouseMoveEvent(event)
    event.setLocalPos(graphics.mapFromScene(QtCore.QPointF(1, 11)))
    graphics.mouseMoveEvent(event)
    graphics.mouseReleaseEvent(event)

    assert np.all(graphics.mask[0, :])
    assert np.all(graphics.mask[:, -1])
    assert np.all(graphics.mask[1:, :-1] == 0)

    assert graphics.posInSelection(graphics.mapFromScene(QtCore.QPoint(1, 1)))
    assert not graphics.posInSelection(graphics.mapFromScene(QtCore.QPoint(11, 11)))
Beispiel #7
0
def test_laser_graphics_widgets(qtbot: QtBot):
    graphics = LaserGraphicsView(GraphicsOptions())
    qtbot.addWidget(graphics)

    x = np.random.random((10, 10))
    graphics.drawImage(x, QtCore.QRectF(0, 0, 100, 100), "x")

    qtbot.waitExposed(graphics)

    graphics.startRulerWidget()
    assert graphics.widget is not None
    event = QtGui.QMouseEvent(
        QtCore.QEvent.MouseButtonPress,
        graphics.mapFromScene(QtCore.QPointF(0, 0)),
        QtCore.Qt.LeftButton,
        QtCore.Qt.LeftButton,
        QtCore.Qt.NoModifier,
    )
    graphics.mousePressEvent(event)
    event.setLocalPos(graphics.mapFromScene(QtCore.QPointF(100, 100)))
    graphics.mouseMoveEvent(event)
    graphics.mouseReleaseEvent(event)

    assert np.isclose(graphics.widget.line.length(), 100 * np.sqrt(2))

    graphics.endWidget()
    assert graphics.widget is None

    graphics.startSliceWidget()
    assert graphics.widget is not None
    event = QtGui.QMouseEvent(
        QtCore.QEvent.MouseButtonPress,
        graphics.mapFromScene(QtCore.QPointF(5, 5)),
        QtCore.Qt.LeftButton,
        QtCore.Qt.LeftButton,
        QtCore.Qt.NoModifier,
    )
    graphics.mousePressEvent(event)
    event.setLocalPos(graphics.mapFromScene(QtCore.QPointF(95, 5)))
    graphics.mouseMoveEvent(event)
    graphics.mouseReleaseEvent(event)

    assert np.all(graphics.widget.sliced == x[0, :])
Beispiel #8
0
def test_laser_graphics_zoom(qtbot: QtBot):
    graphics = LaserGraphicsView(GraphicsOptions())
    qtbot.addWidget(graphics)

    x = np.random.random((10, 10))
    graphics.drawImage(x, QtCore.QRectF(0, 0, 100, 100), "x")

    qtbot.waitExposed(graphics)

    graphics.zoomStart()

    event = QtGui.QMouseEvent(
        QtCore.QEvent.MouseButtonPress,
        graphics.mapFromScene(QtCore.QPointF(20, 20)),
        QtCore.Qt.LeftButton,
        QtCore.Qt.LeftButton,
        QtCore.Qt.NoModifier,
    )

    graphics.mousePressEvent(event)
    event.setLocalPos(graphics.mapFromScene(QtCore.QPointF(40, 40)))
    graphics.mouseMoveEvent(event)
    graphics.mouseReleaseEvent(event)

    rect = graphics.mapToScene(graphics.viewport().rect()).boundingRect()
    assert 29.5 < rect.center().x() < 30.5
    assert 29.5 < rect.center().y() < 30.5
    assert 19.5 < rect.width() < 20.5 or 19.5 < rect.height() < 20.5
Beispiel #9
0
class FilteringTool(ToolWidget):
    """View and calculate mean and meidan filtered images."""

    methods: dict = {
        "Rolling Mean": {
            "filter":
            rolling_mean,
            "params": [
                ("size", 5, (2.5, 99), lambda x: (x + 1) % 2 == 0),
                ("σ", 3.0, (0.0, np.inf), None),
            ],
            "desc": [
                "Window size for local mean.",
                "Filter if > σ stddevs from mean."
            ],
        },
        "Rolling Median": {
            "filter":
            rolling_median,
            "params": [
                ("size", 5, (2.5, 99), lambda x: (x + 1) % 2 == 0),
                ("M", 3.0, (0.0, np.inf), None),
            ],
            "desc": [
                "Window size for local median.",
                "Filter if > M medians from median.",
            ],
        },
        # "Simple High-pass": {
        #     "filter": simple_highpass,
        #     "params": [
        #         ("min", 1e3, (-np.inf, np.inf), None),
        #         ("replace", 0.0, (-np.inf, np.inf), None),
        #     ],
        #     "desc": ["Filter if below this value.", "Value to replace with."],
        # },
        # "Simple Low-pass": {
        #     "filter": simple_lowpass,
        #     "params": [
        #         ("max", 1e3, (-np.inf, np.inf), None),
        #         ("replace", 0.0, (-np.inf, np.inf), None),
        #     ],
        #     "desc": ["Filter if above this value.", "Value to replace with."],
        # },
    }

    def __init__(self, widget: LaserWidget):
        super().__init__(widget, graphics_label="Preview")

        self.graphics = LaserGraphicsView(self.viewspace.options, parent=self)
        self.graphics.cursorValueChanged.connect(
            self.widget.updateCursorStatus)
        self.graphics.setMouseTracking(True)

        self.action_toggle_filter = qAction(
            "visibility",
            "Filter Visible",
            "Toggle visibility of the filtering.",
            self.toggleFilter,
        )
        self.action_toggle_filter.setCheckable(True)
        self.button_hide_filter = qToolButton(action=self.action_toggle_filter)
        self.button_hide_filter.setToolButtonStyle(
            QtCore.Qt.ToolButtonTextBesideIcon)

        self.combo_element = QtWidgets.QComboBox()
        self.combo_element.activated.connect(self.completeChanged)
        self.combo_element.activated.connect(self.refresh)

        self.combo_filter = QtWidgets.QComboBox()
        self.combo_filter.addItems(FilteringTool.methods.keys())
        self.combo_filter.setCurrentText("Rolling Median")
        self.combo_filter.activated.connect(self.filterChanged)
        self.combo_filter.activated.connect(self.completeChanged)
        self.combo_filter.activated.connect(self.refresh)

        nparams = np.amax(
            [len(f["params"]) for f in FilteringTool.methods.values()])
        self.label_fparams = [QtWidgets.QLabel() for _ in range(nparams)]
        self.lineedit_fparams = [ValidColorLineEdit() for _ in range(nparams)]
        for le in self.lineedit_fparams:
            le.textEdited.connect(self.completeChanged)
            le.editingFinished.connect(self.refresh)
            le.setValidator(
                ConditionalLimitValidator(0.0, 0.0, 4, condition=None))

        layout_graphics = QtWidgets.QVBoxLayout()
        layout_graphics.addWidget(self.graphics)
        layout_graphics.addWidget(self.combo_element, 0, QtCore.Qt.AlignRight)
        self.box_graphics.setLayout(layout_graphics)

        layout_controls = QtWidgets.QFormLayout()
        layout_controls.addWidget(self.combo_filter)
        for i in range(len(self.label_fparams)):
            layout_controls.addRow(self.label_fparams[i],
                                   self.lineedit_fparams[i])
        layout_controls.addWidget(self.button_hide_filter)

        self.box_controls.setLayout(layout_controls)

        self.initialise()

    def apply(self) -> None:
        self.modified = True
        name = self.combo_element.currentText()
        if self.button_hide_filter.isChecked():
            self.widget.laser.data[name] = self.previewData(
                self.widget.laser.data[name])
        else:
            self.widget.laser.data[name] = self.graphics.data

        self.initialise()

    @property
    def fparams(self) -> List[float]:
        return [
            float(le.text()) for le in self.lineedit_fparams if le.isEnabled()
        ]

    def filterChanged(self) -> None:
        filter_ = FilteringTool.methods[self.combo_filter.currentText()]
        # Clear all the current params
        for le in self.label_fparams:
            le.setVisible(False)
        for le in self.lineedit_fparams:
            le.setVisible(False)

        params: List[Tuple[str, float, Tuple,
                           Callable[[float], bool]]] = filter_["params"]

        for i, (symbol, default, range, condition) in enumerate(params):
            self.label_fparams[i].setText(f"{symbol}:")
            self.label_fparams[i].setVisible(True)
            self.lineedit_fparams[i].validator().setRange(
                range[0], range[1], 4)
            self.lineedit_fparams[i].validator().setCondition(condition)
            self.lineedit_fparams[i].setVisible(True)
            self.lineedit_fparams[i].setToolTip(filter_["desc"][i])
            # keep input that's still valid
            if not self.lineedit_fparams[i].hasAcceptableInput():
                self.lineedit_fparams[i].setText(str(default))
                self.lineedit_fparams[i].revalidate()

    def initialise(self) -> None:
        elements = self.widget.laser.elements
        self.combo_element.clear()
        self.combo_element.addItems(elements)

        self.filterChanged()
        self.refresh()

    def isComplete(self) -> bool:
        if not all(le.hasAcceptableInput()
                   for le in self.lineedit_fparams if le.isEnabled()):
            return False
        return True

    def previewData(self, data: np.ndarray) -> np.ndarray:
        filter_ = FilteringTool.methods[
            self.combo_filter.currentText()]["filter"]
        return filter_(data, *self.fparams)

    def refresh(self) -> None:
        if not self.isComplete():  # Not ready for update to preview
            return

        element = self.combo_element.currentText()

        data = self.widget.laser.get(element, flat=True, calibrated=False)
        if not self.button_hide_filter.isChecked():
            data = self.previewData(data)
        if data is None:
            return

        x0, x1, y0, y1 = self.widget.laser.config.data_extent(data.shape)
        rect = QtCore.QRectF(x0, y0, x1 - x0, y1 - y0)

        self.graphics.drawImage(data, rect, element)
        self.graphics.label.setText(element)

        self.graphics.setOverlayItemVisibility()
        self.graphics.updateForeground()
        self.graphics.invalidateScene()

    def toggleFilter(self, hide: bool) -> None:
        if hide:
            self.button_hide_filter.setIcon(QtGui.QIcon.fromTheme("hint"))
        else:
            self.button_hide_filter.setIcon(
                QtGui.QIcon.fromTheme("visibility"))

        self.refresh()
Beispiel #10
0
class CalculatorTool(ToolWidget):
    """Calculator for element data operations."""

    functions = {
        "abs": (
            (UnaryFunction("abs"), "(<x>)", "The absolute value of <x>."),
            (np.abs, 1),
        ),
        "kmeans": (
            (
                BinaryFunction("kmeans"),
                "(<x>, <k>)",
                "Returns lower bounds of 1 to <k> kmeans clusters.",
            ),
            (kmeans.thresholds, 2),
        ),
        "mask": (
            (
                BinaryFunction("mask"),
                "(<x>, <mask>)",
                "Selects <x> where <mask>, otherwise NaN.",
            ),
            (lambda x, m: np.where(m, x, np.nan), 2),
        ),
        "mean": (
            (UnaryFunction("mean"), "(<x>)", "Returns the mean of <x>."),
            (np.nanmean, 1),
        ),
        "median": (
            (
                UnaryFunction("median"),
                "(<x>)",
                "Returns the median of <x>.",
            ),
            (np.nanmedian, 1),
        ),
        "nantonum": (
            (UnaryFunction("nantonum"), "(<x>)", "Sets nan values to 0."),
            (np.nan_to_num, 1),
        ),
        "normalise": (
            (
                TernaryFunction("normalise"),
                "(<x>, <min>, <max>)",
                "Normalise <x> from from <min> to <max>.",
            ),
            (normalise, 3),
        ),
        "otsu": (
            (
                UnaryFunction("otsu"),
                "(<x>)",
                "Returns Otsu's threshold for <x>.",
            ),
            (otsu, 1),
        ),
        "percentile": (
            (
                BinaryFunction("percentile"),
                "(<x>, <percent>)",
                "Returns the <percent> percentile of <x>.",
            ),
            (np.nanpercentile, 2),
        ),
        "segment": (
            (
                BinaryFunction("segment"),
                "(<x>, <threshold(s)>)",
                "Create a masking image from the given thrshold(s).",
            ),
            (segment_image, 2),
        ),
        "threshold": (
            (
                BinaryFunction("threshold"),
                "(<x>, <value>)",
                "Sets <x> below <value> to NaN.",
            ),
            (lambda x, a: np.where(x > a, x, np.nan), 2),
        ),
    }

    def __init__(self, widget: LaserWidget):
        super().__init__(widget, graphics_label="Preview")

        self.graphics = LaserGraphicsView(self.viewspace.options, parent=self)
        self.graphics.cursorValueChanged.connect(self.widget.updateCursorStatus)
        self.graphics.setMouseTracking(True)

        self.output = QtWidgets.QLineEdit("Result")
        self.output.setEnabled(False)

        self.lineedit_name = CalculatorName(
            "",
            badnames=[],
            badparser=list(CalculatorTool.functions.keys()),
        )
        self.lineedit_name.revalidate()
        self.lineedit_name.textEdited.connect(self.completeChanged)
        self.lineedit_name.editingFinished.connect(self.refresh)

        self.combo_element = QtWidgets.QComboBox()
        self.combo_element.activated.connect(self.insertVariable)

        functions = [k + v[0][1] for k, v in CalculatorTool.functions.items()]
        tooltips = [v[0][2] for v in CalculatorTool.functions.values()]
        self.combo_function = QtWidgets.QComboBox()
        self.combo_function.addItem("Functions")
        self.combo_function.addItems(functions)
        for i in range(0, len(tooltips)):
            self.combo_function.setItemData(i + 1, tooltips[i], QtCore.Qt.ToolTipRole)
        self.combo_function.activated.connect(self.insertFunction)

        self.reducer = Reducer({})
        self.formula = CalculatorFormula("", variables=[])
        self.formula.textChanged.connect(self.completeChanged)
        self.formula.textChanged.connect(self.refresh)

        self.reducer.operations.update(
            {k: v[1] for k, v in CalculatorTool.functions.items()}
        )
        self.formula.parser.nulls.update(
            {k: v[0][0] for k, v in CalculatorTool.functions.items()}
        )

        layout_combos = QtWidgets.QHBoxLayout()
        layout_combos.addWidget(self.combo_element)
        layout_combos.addWidget(self.combo_function)

        layout_graphics = QtWidgets.QVBoxLayout()
        layout_graphics.addWidget(self.graphics)
        self.box_graphics.setLayout(layout_graphics)

        layout_controls = QtWidgets.QFormLayout()
        layout_controls.addRow("Name:", self.lineedit_name)
        layout_controls.addRow("Insert:", layout_combos)
        layout_controls.addRow("Formula:", self.formula)
        layout_controls.addRow("Result:", self.output)
        self.box_controls.setLayout(layout_controls)

        self.initialise()  # refreshes

    def apply(self) -> None:
        self.modified = True
        name = self.lineedit_name.text()
        data = self.reducer.reduce(self.formula.expr)
        if name in self.widget.laser.elements:
            self.widget.laser.data[name] = data
        else:
            self.widget.laser.add(self.lineedit_name.text(), data)
        # Make sure to repop elements
        self.widget.populateElements()

        self.initialise()

    def initialise(self) -> None:
        elements = self.widget.laser.elements
        self.combo_element.clear()
        self.combo_element.addItem("Elements")
        self.combo_element.addItems(elements)

        name = "calc0"
        i = 1
        while name in elements:
            name = f"calc{i}"
            i += 1
        self.lineedit_name.setText(name)
        self.formula.parser.variables = elements
        self.formula.setCompleter(
            QtWidgets.QCompleter(
                list(self.formula.parser.variables)
                + [k + "(" for k in CalculatorTool.functions.keys()]
            )
        )
        self.formula.valid = True
        self.formula.setText(self.widget.combo_element.currentText())  # refreshes

    def insertFunction(self, index: int) -> None:
        if index == 0:
            return
        function = self.combo_function.itemText(index)
        function = function[: function.find("(") + 1]
        self.formula.insertPlainText(function)
        self.combo_function.setCurrentIndex(0)
        self.formula.setFocus()

    def insertVariable(self, index: int) -> None:
        if index == 0:
            return
        self.formula.insertPlainText(self.combo_element.itemText(index))
        self.combo_element.setCurrentIndex(0)
        self.formula.setFocus()

    def isComplete(self) -> bool:
        if not self.formula.hasAcceptableInput():
            return False
        if not self.lineedit_name.hasAcceptableInput():
            return False
        return True

    def previewData(self, data: np.ndarray) -> Optional[np.ndarray]:
        self.reducer.variables = {name: data[name] for name in data.dtype.names}
        try:
            result = self.reducer.reduce(self.formula.expr)
            if np.isscalar(result):
                self.output.setText(f"{result:.10g}")
                return None
            elif isinstance(result, np.ndarray) and result.ndim == 1:
                self.output.setText(f"{list(map('{:.4g}'.format, result))}")
                return None
            elif isinstance(result, np.ndarray):
                self.output.setText(f"{result.dtype.name} array: {result.shape}")
                return result
        except (ReducerException, ValueError) as e:
            self.output.setText(str(e))
            return None

    def refresh(self) -> None:
        if not self.isComplete():  # Not ready for update to preview
            return

        data = self.previewData(self.widget.laser.get(flat=True, calibrated=False))
        if data is None:
            return
        x0, x1, y0, y1 = self.widget.laser.config.data_extent(data.shape)
        rect = QtCore.QRectF(x0, y0, x1 - x0, y1 - y0)

        self.graphics.drawImage(data, rect, self.lineedit_name.text())

        self.graphics.label.setText(self.lineedit_name.text())

        self.graphics.setOverlayItemVisibility()
        self.graphics.updateForeground()
        self.graphics.invalidateScene()