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)
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)))