class SpectraPlotWidget(PlotWidget): sigEnergyChanged = Signal(object) def __init__(self, *args, **kwargs): super(SpectraPlotWidget, self).__init__(*args, **kwargs) self._data = None self.positionmap = dict() self.wavenumbers = None self._meanSpec = True # whether current spectrum is a mean spectrum self.line = InfiniteLine(movable=True) self.line.setPen((255, 255, 0, 200)) self.line.setZValue(100) self.line.sigPositionChanged.connect(self.sigEnergyChanged) self.line.sigPositionChanged.connect(self.getEnergy) self.addItem(self.line) self.getViewBox().invertX(True) self.selectedPixels = None self._y = None def getEnergy(self, lineobject): if self._y is not None: x_val = lineobject.value() idx = val2ind(x_val, self.wavenumbers) x_val = self.wavenumbers[idx] y_val = self._y[idx] if not self._meanSpec: txt_html = f'<div style="text-align: center"><span style="color: #FFF; font-size: 12pt">\ Spectrum #{self.spectrumInd}</div>' else: txt_html = f'<div style="text-align: center"><span style="color: #FFF; font-size: 12pt">\ {self._mean_title}</div>' txt_html += f'<div style="text-align: center"><span style="color: #FFF; font-size: 12pt">\ X = {x_val: .2f}, Y = {y_val: .4f}</div>' self.txt.setHtml(txt_html) def setHeader(self, header: NonDBHeader, field: str, *args, **kwargs): self.header = header self.field = field # get wavenumbers spectraEvent = next(header.events(fields=['spectra'])) self.wavenumbers = spectraEvent['wavenumbers'] self.N_w = len(self.wavenumbers) self.rc2ind = spectraEvent['rc_index'] # make lazy array from document data = None try: data = header.meta_array(field) except IndexError: msg.logMessage('Header object contained no frames with field ''{field}''.', msg.ERROR) if data is not None: # kwargs['transform'] = QTransform(1, 0, 0, -1, 0, data.shape[-2]) self._data = data def showSpectra(self, i=0): if self._data is not None: self.clear() self._meanSpec = False self.spectrumInd = i self.plot(self.wavenumbers, self._data[i]) def getSelectedPixels(self, selectedPixels): self.selectedPixels = selectedPixels # print(selectedPixels) def showMeanSpectra(self): self._meanSpec = True self.clear() if self.selectedPixels is not None: n_spectra = len(self.selectedPixels) tmp = np.zeros((n_spectra, self.N_w)) for j in range(n_spectra): # j: jth selected pixel row_col = tuple(self.selectedPixels[j]) tmp[j, :] = self._data[self.rc2ind[row_col]] self._mean_title = f'ROI mean of {n_spectra} spectra' else: n_spectra = len(self._data) tmp = np.zeros((n_spectra, self.N_w)) for j in range(n_spectra): tmp[j, :] = self._data[j] self._mean_title = f'Total mean of {n_spectra} spectra' if n_spectra > 0: meanSpec = np.mean(tmp, axis=0) else: meanSpec = np.zeros_like(self.wavenumbers) + 1e-3 self.plot(self.wavenumbers, meanSpec) def plot(self, x, y, *args, **kwargs): # set up infinity line and get its position self.plotItem.plot(x, y, *args, **kwargs) self.addItem(self.line) x_val = self.line.value() if x_val == 0: y_val = 0 else: idx = val2ind(x_val, self.wavenumbers) x_val = self.wavenumbers[idx] y_val = y[idx] if not self._meanSpec: txt_html = f'<div style="text-align: center"><span style="color: #FFF; font-size: 12pt">\ Spectrum #{self.spectrumInd}</div>' else: txt_html = f'<div style="text-align: center"><span style="color: #FFF; font-size: 12pt">\ {self._mean_title}</div>' txt_html += f'<div style="text-align: center"><span style="color: #FFF; font-size: 12pt">\ X = {x_val: .2f}, Y = {y_val: .4f}</div>' self.txt = TextItem(html=txt_html, anchor=(0, 0)) ymax = max(y) self._y = y self.txt.setPos(1500, 0.95 * ymax) self.addItem(self.txt)
class ScanPlotWidget(PlotWidget): """ Extend the PlotWidget Class with more functionality used for qudi scan images. Supported features: - draggable/static crosshair with optional range and size constraints. - zoom feature by rubberband selection - rubberband area selection This class depends on the ScanViewBox class defined further below. This class can be promoted in the Qt designer. """ sigMouseAreaSelected = QtCore.Signal( QtCore.QRectF) # mapped rectangle mouse cursor selection sigCrosshairPosChanged = QtCore.Signal(QtCore.QPointF) sigCrosshairDraggedPosChanged = QtCore.Signal(QtCore.QPointF) def __init__(self, *args, **kwargs): kwargs['viewBox'] = ScanViewBox() # Use custom pg.ViewBox subclass super().__init__(*args, **kwargs) self.getViewBox().sigMouseAreaSelected.connect( self.sigMouseAreaSelected) self._min_crosshair_factor = 0.02 self._crosshair_size = (0, 0) self._crosshair_range = None self.getViewBox().sigRangeChanged.connect( self._constraint_crosshair_size) self.crosshair = ROI((0, 0), (0, 0), pen={ 'color': '#00ff00', 'width': 1 }) self.hline = InfiniteLine(pos=0, angle=0, movable=True, pen={ 'color': '#00ff00', 'width': 1 }, hoverPen={ 'color': '#ffff00', 'width': 1 }) self.vline = InfiniteLine(pos=0, angle=90, movable=True, pen={ 'color': '#00ff00', 'width': 1 }, hoverPen={ 'color': '#ffff00', 'width': 1 }) self.vline.sigDragged.connect(self._update_pos_from_line) self.hline.sigDragged.connect(self._update_pos_from_line) self.crosshair.sigRegionChanged.connect(self._update_pos_from_roi) self.sigCrosshairDraggedPosChanged.connect(self.sigCrosshairPosChanged) @property def crosshair_enabled(self): items = self.items() return (self.vline in items) and (self.hline in items) and (self.crosshair in items) @property def crosshair_movable(self): return bool(self.crosshair.translatable) @property def crosshair_position(self): pos = self.vline.pos() pos[1] = self.hline.pos()[1] return tuple(pos) @property def crosshair_size(self): return tuple(self._crosshair_size) @property def crosshair_min_size_factor(self): return float(self._min_crosshair_factor) @property def crosshair_range(self): if self._crosshair_range is None: return None return tuple(self._crosshair_range) @property def selection_enabled(self): return bool(self.getViewBox().rectangle_selection) @property def zoom_by_selection_enabled(self): return bool(self.getViewBox().zoom_by_selection) def toggle_selection(self, enable): """ De-/Activate the rectangular rubber band selection tool. If active you can select a rectangular region within the ViewBox by dragging the mouse with the left button. Each selection rectangle in real-world data coordinates will be emitted by sigMouseAreaSelected. By using activate_zoom_by_selection you can optionally de-/activate zooming in on the selection. @param bool enable: Toggle selection on (True) or off (False) """ return self.getViewBox().toggle_selection(enable) def toggle_zoom_by_selection(self, enable): """ De-/Activate automatic zooming into a selection. See also: toggle_selection @param bool enable: Toggle zoom upon selection on (True) or off (False) """ return self.getViewBox().toggle_zoom_by_selection(enable) def _update_pos_from_line(self, obj): """ Called each time the position of the InfiniteLines has been changed by a user drag. Causes the crosshair rectangle to follow the lines. """ if obj not in (self.hline, self.vline): return pos = self.vline.pos() pos[1] = self.hline.pos()[1] size = self.crosshair.size() self.crosshair.blockSignals(True) self.crosshair.setPos((pos[0] - size[0] / 2, pos[1] - size[1] / 2)) self.crosshair.blockSignals(False) self.sigCrosshairDraggedPosChanged.emit(QtCore.QPointF(pos[0], pos[1])) return def _update_pos_from_roi(self, obj): """ Called each time the position of the rectangular ROI has been changed by a user drag. Causes the InfiniteLines to follow the ROI. """ if obj is not self.crosshair: return pos = self.crosshair.pos() size = self.crosshair.size() pos[0] += size[0] / 2 pos[1] += size[1] / 2 self.vline.setPos(pos[0]) self.hline.setPos(pos[1]) self.sigCrosshairDraggedPosChanged.emit(QtCore.QPointF(pos[0], pos[1])) return def toggle_crosshair(self, enable, movable=True): """ Disable/Enable the crosshair within the PlotWidget. Optionally also toggle if it can be dragged by the user. @param bool enable: enable crosshair (True), disable crosshair (False) @param bool movable: enable user drag (True), disable user drag (False) """ if not isinstance(enable, bool): raise TypeError('Positional argument "enable" must be bool type.') if not isinstance(movable, bool): raise TypeError('Optional argument "movable" must be bool type.') self.toggle_crosshair_movable(movable) is_enabled = self.crosshair_enabled if enable and not is_enabled: self.addItem(self.vline) self.addItem(self.hline) self.addItem(self.crosshair) elif not enable and is_enabled: self.removeItem(self.vline) self.removeItem(self.hline) self.removeItem(self.crosshair) return def toggle_crosshair_movable(self, enable): """ Toggle if the crosshair can be dragged by the user. @param bool enable: enable (True), disable (False) """ self.crosshair.translatable = bool(enable) self.vline.setMovable(enable) self.hline.setMovable(enable) return def set_crosshair_pos(self, pos): """ Set the crosshair center to the given coordinates. @param QPointF|float[2] pos: (x,y) position of the crosshair """ try: pos = tuple(pos) except TypeError: pos = (pos.x(), pos.y()) size = self.crosshair.size() self.crosshair.blockSignals(True) self.vline.blockSignals(True) self.hline.blockSignals(True) self.crosshair.setPos(pos[0] - size[0] / 2, pos[1] - size[1] / 2) self.vline.setPos(pos[0]) self.hline.setPos(pos[1]) self.crosshair.blockSignals(False) self.vline.blockSignals(False) self.hline.blockSignals(False) self.sigCrosshairPosChanged.emit(QtCore.QPointF(*pos)) return def set_crosshair_size(self, size, force_default=True): """ Set the default size of the crosshair rectangle (x, y) and update the display. @param QSize|float[2] size: the (x,y) size of the crosshair rectangle @param bool force_default: Set default crosshair size and enforce minimal size (True). Enforce displayed crosshair size while keeping default size untouched (False). """ try: size = tuple(size) except TypeError: size = (size.width(), size.height()) if force_default: if size[0] <= 0 and size[1] <= 0: self._crosshair_size = (0, 0) else: self._crosshair_size = size # Check if actually displayed size needs to be adjusted due to minimal size size = self._get_corrected_crosshair_size(size) pos = self.vline.pos() pos[1] = self.hline.pos()[1] - size[1] / 2 pos[0] -= size[0] / 2 if self._crosshair_range: crange = self._crosshair_range self.crosshair.maxBounds = QtCore.QRectF( crange[0][0] - size[0] / 2, crange[1][0] - size[1] / 2, crange[0][1] - crange[0][0] + size[0], crange[1][1] - crange[1][0] + size[1]) self.crosshair.blockSignals(True) self.crosshair.setSize(size) self.crosshair.setPos(pos) self.crosshair.blockSignals(False) return def set_crosshair_min_size_factor(self, factor): """ Sets the minimum crosshair size factor. This will determine the minimum size of the smallest edge of the crosshair center rectangle. This minimum size is calculated by taking the smallest visible axis of the ViewBox and multiplying it with the scale factor set by this method. The crosshair rectangle will be then scaled accordingly if the set crosshair size is smaller than this minimal size. @param float factor: The scale factor to set. If <= 0 no minimal crosshair size enforced. """ if factor <= 0: self._min_crosshair_factor = 0 elif factor <= 1: self._min_crosshair_factor = float(factor) else: raise ValueError('Crosshair min size factor must be a value <= 1.') return def set_crosshair_range(self, new_range): """ Sets a range boundary for the crosshair position. @param float[2][2] new_range: two min-max range value tuples (for x and y axis). If None set unlimited ranges. """ if new_range is None: self.vline.setBounds([None, None]) self.hline.setBounds([None, None]) self.crosshair.maxBounds = None else: self.vline.setBounds(new_range[0]) self.hline.setBounds(new_range[1]) size = self.crosshair.size() pos = self.crosshair_position self.crosshair.maxBounds = QtCore.QRectF( new_range[0][0] - size[0] / 2, new_range[1][0] - size[1] / 2, new_range[0][1] - new_range[0][0] + size[0], new_range[1][1] - new_range[1][0] + size[1]) self.crosshair.setPos(pos[0] - size[0] / 2, pos[1] - size[1] / 2) self._crosshair_range = new_range return def set_crosshair_pen(self, pen): """ Sets the pyqtgraph compatible pen to be used for drawing the crosshair lines. @param pen: pyqtgraph compatible pen to use """ self.crosshair.setPen(pen) self.vline.setPen(pen) self.hline.setPen(pen) return def _constraint_crosshair_size(self): if self._min_crosshair_factor == 0: return if self._crosshair_size[0] == 0 or self._crosshair_size[1] == 0: return corr_size = self._get_corrected_crosshair_size(self._crosshair_size) if corr_size != tuple(self.crosshair.size()): self.set_crosshair_size(corr_size, force_default=False) return def _get_corrected_crosshair_size(self, size): try: size = tuple(size) except TypeError: size = (size.width(), size.height()) min_size = min(size) if min_size == 0: return size vb_size = self.getViewBox().viewRect().size() short_index = int(vb_size.width() > vb_size.height()) min_vb_size = vb_size.width() if short_index == 0 else vb_size.height() min_vb_size *= self._min_crosshair_factor if min_size < min_vb_size: scale_factor = min_vb_size / min_size size = (size[0] * scale_factor, size[1] * scale_factor) return size
class SpectraPlotWidget(PlotWidget): sigEnergyChanged = Signal(object) def __init__(self, linePos=650, txtPosRatio=0.35, invertX=True, *args, **kwargs): """ A widget to display a 1D spectrum :param linePos: the initial position of the InfiniteLine :param txtPosRatio: a coefficient that determines the relative position of the textItem :param invertX: whether to invert X-axis """ super(SpectraPlotWidget, self).__init__(*args, **kwargs) self._data = None assert (txtPosRatio >= 0) and (txtPosRatio <= 1), 'Please set txtPosRatio value between 0 and 1.' self.txtPosRatio = txtPosRatio self.positionmap = dict() self.wavenumbers = None self._meanSpec = True # whether current spectrum is a mean spectrum self.line = InfiniteLine(movable=True) self.line.setPen((255, 255, 0, 200)) self.line.setValue(linePos) self.line.sigPositionChanged.connect(self.sigEnergyChanged) self.line.sigPositionChanged.connect(self.getEnergy) self.addItem(self.line) self.cross = PlotDataItem([linePos], [0], symbolBrush=(255, 0, 0), symbolPen=(255, 0, 0), symbol='+', symbolSize=20) self.cross.setZValue(100) self.addItem(self.cross) self.txt = TextItem() self.getViewBox().invertX(invertX) self.spectrumInd = 0 self.selectedPixels = None self._y = None def getEnergy(self, lineobject): if self._y is not None: x_val = lineobject.value() idx = val2ind(x_val, self.wavenumbers) x_val = self.wavenumbers[idx] y_val = self._y[idx] if not self._meanSpec: txt_html = toHtml(f'Spectrum #{self.spectrumInd}') else: txt_html = toHtml(f'{self._mean_title}') txt_html += toHtml(f'X = {x_val: .2f}, Y = {y_val: .4f}') self.txt.setHtml(txt_html) self.cross.setData([x_val], [y_val]) def setHeader(self, header: NonDBHeader, field: str, *args, **kwargs): self.header = header self.field = field # get wavenumbers spectraEvent = next(header.events(fields=['spectra'])) self.wavenumbers = spectraEvent['wavenumbers'] self.N_w = len(self.wavenumbers) self.rc2ind = spectraEvent['rc_index'] # make lazy array from document data = None try: data = header.meta_array(field) except IndexError: msg.logMessage(f'Header object contained no frames with field {field}.', msg.ERROR) if data is not None: # kwargs['transform'] = QTransform(1, 0, 0, -1, 0, data.shape[-2]) self._data = data def showSpectra(self, i=0): if (self._data is not None) and (i < len(self._data)): self.getViewBox().clear() self._meanSpec = False self.spectrumInd = i self.plot(self.wavenumbers, self._data[i]) def getSelectedPixels(self, selectedPixels): self.selectedPixels = selectedPixels # print(selectedPixels) def clearAll(self): # remove legend _legend = self.plotItem.legend if (_legend is not None) and (_legend.scene() is not None): _legend.scene().removeItem(_legend) self.getViewBox().clear() def showMeanSpectra(self): self._meanSpec = True self.getViewBox().clear() if self.selectedPixels is not None: n_spectra = len(self.selectedPixels) tmp = np.zeros((n_spectra, self.N_w)) for j in range(n_spectra): # j: jth selected pixel row_col = tuple(self.selectedPixels[j]) tmp[j, :] = self._data[self.rc2ind[row_col]] self._mean_title = f'ROI mean of {n_spectra} spectra' else: n_spectra = len(self._data) tmp = np.zeros((n_spectra, self.N_w)) for j in range(n_spectra): tmp[j, :] = self._data[j] self._mean_title = f'Total mean of {n_spectra} spectra' if n_spectra > 0: meanSpec = np.mean(tmp, axis=0) else: meanSpec = np.zeros_like(self.wavenumbers) + 1e-3 self.plot(self.wavenumbers, meanSpec) def plot(self, x, y, *args, **kwargs): # set up infinity line and get its position self.plotItem.plot(x, y, *args, **kwargs) self.addItem(self.line) self.addItem(self.cross) x_val = self.line.value() if x_val == 0: y_val = 0 else: idx = val2ind(x_val, self.wavenumbers) x_val = self.wavenumbers[idx] y_val = y[idx] if not self._meanSpec: txt_html = toHtml(f'Spectrum #{self.spectrumInd}') else: txt_html = toHtml(f'{self._mean_title}') txt_html += toHtml(f'X = {x_val: .2f}, Y = {y_val: .4f}') self.txt.setHtml(txt_html) ymax = np.max(y) self._y = y r = self.txtPosRatio self.txt.setPos(r * x[-1] + (1 - r) * x[0], ymax) self.cross.setData([x_val], [y_val]) self.addItem(self.txt)
class SliderGraph(PlotWidget): """ An widget graph element that shows a line plot with more sequences. It also plot a vertical line that can be moved left and right by a user. When the line is moved a callback function is called with selected value (on x axis). Attributes ---------- x_axis_label : str A text label for x axis y_axis_label : str A text label for y axis callback : callable A function which is called when selection is changed. background : str, optional (default: "w") Plot background color """ def __init__(self, x_axis_label, y_axis_label, callback): super().__init__(background="w") axis = self.getAxis("bottom") axis.setLabel(x_axis_label) axis = self.getAxis("left") axis.setLabel(y_axis_label) self.getViewBox().setMenuEnabled(False) self.getViewBox().setMouseEnabled(False, False) self.showGrid(True, True, alpha=0.5) self.setRange(xRange=(0.0, 1.0), yRange=(0.0, 1.0)) self.hideButtons() # tuples to store horisontal lines and labels self.plot_horlabel = [] self.plot_horline = [] self._line = None self.callback = callback # variables to store sequences self.sequences = None self.x = None self.selection_limit = None self.data_increasing = None # true if data mainly increasing def update(self, x, y, colors, cutpoint_x=None, selection_limit=None, names=None): """ Function replots a graph. Parameters ---------- x : np.ndarray One-dimensional array with X coordinates of the points y : array-like List of np.ndarrays that contains an array of Y values for each sequence. colors : array-like List of Qt colors (eg. Qt.red) for each sequence. cutpoint_x : int, optional A starting cutpoint - the location of the vertical line. selection_limit : tuple The tuple of two values that limit the range for selection. names : array-like The name of each sequence that shows in the legend, if None legend is not shown. legend_anchor : array-like The anchor of the legend in the graph """ self.clear_plot() if names is None: names = [None] * len(y) self.sequences = y self.x = x self.selection_limit = selection_limit self.data_increasing = [np.sum(d[1:] - d[:-1]) > 0 for d in y] # plot sequence for s, c, n, inc in zip(y, colors, names, self.data_increasing): c = QColor(c) self.plot(x, s, pen=mkPen(c, width=2), antialias=True) if n is not None: label = TextItem(text=n, anchor=(0, 1), color=QColor(0, 0, 0, 128)) label.setPos(x[-1], s[-1]) self._set_anchor(label, len(x) - 1, inc) self.addItem(label) self._plot_cutpoint(cutpoint_x) self.autoRange() def clear_plot(self): """ This function clears the plot and removes data. """ self.clear() self.setRange(xRange=(0.0, 1.0), yRange=(0.0, 1.0)) self.plot_horlabel = [] self.plot_horline = [] self._line = None self.sequences = None def set_cut_point(self, x): """ This function sets the cutpoint (selection line) at the specific location. Parameters ---------- x : int Cutpoint location at the x axis. """ self._plot_cutpoint(x) def _plot_cutpoint(self, x): """ Function plots the cutpoint. Parameters ---------- x : int Cutpoint location. """ if x is None: self._line = None return if self._line is None: # plot interactive vertical line self._line = InfiniteLine(angle=90, pos=x, movable=True, bounds=self.selection_limit if self.selection_limit is not None else (self.x.min(), self.x.max())) self._line.setCursor(Qt.SizeHorCursor) self._line.setPen(mkPen(QColor(Qt.black), width=2)) self._line.sigPositionChanged.connect(self._on_cut_changed) self.addItem(self._line) else: self._line.setValue(x) self._update_horizontal_lines() def _plot_horizontal_lines(self): """ Function plots the vertical dashed lines that points to the selected sequence values at the y axis. """ for _ in range(len(self.sequences)): self.plot_horline.append( PlotCurveItem(pen=mkPen(QColor(Qt.blue), style=Qt.DashLine))) self.plot_horlabel.append( TextItem(color=QColor(Qt.black), anchor=(0, 1))) for item in self.plot_horlabel + self.plot_horline: self.addItem(item) def _set_anchor(self, label, cutidx, inc): """ This function set the location of the text label around the selected point at the curve. It place the text such that it is not plotted at the line. Parameters ---------- label : TextItem Text item that needs to have location set. cutidx : int The index of the selected element in the list. If index in first part of the list we put label on the right side else on the left, such that it does not disappear at the graph edge. inc : bool This parameter tels whether the curve value is increasing or decreasing. """ if inc: label.anchor = Point(0, 0) if cutidx < len(self.x) / 2 \ else Point(1, 1) else: label.anchor = Point(0, 1) if cutidx < len(self.x) / 2 \ else Point(1, 0) def _update_horizontal_lines(self): """ This function update the horisontal lines when selection changes. If lines are present jet it calls the function to init them. """ if not self.plot_horline: # init horizontal lines self._plot_horizontal_lines() # in every case set their position location = int(round(self._line.value())) cutidx = np.searchsorted(self.x, location) minx = np.min(self.x) for s, curve, label, inc in zip(self.sequences, self.plot_horline, self.plot_horlabel, self.data_increasing): y = s[cutidx] curve.setData([minx, location], [y, y]) self._set_anchor(label, cutidx, inc) label.setPos(location, y) label.setPlainText("{:.3f}".format(y)) def _on_cut_changed(self, line): """ This function is called when selection changes. It extract the selected value and calls the callback function. Parameters ---------- line : InfiniteLine The cutpoint - selection line. """ # cut changed by means of a cut line over the scree plot. value = int(round(line.value())) # vertical line can take only int positions self._line.setValue(value) self._update_horizontal_lines() self.callback(value)
class ScanPlotWidget(PlotWidget): """ Extend the PlotWidget Class with more functionality used for qudi scan images. Supported features: - draggable/static crosshair with optional range and size constraints. - zoom feature by rubberband selection - rubberband area selection This class depends on the ScanViewBox class defined further below. This class can be promoted in the Qt designer. """ sigMouseAreaSelected = QtCore.Signal(QtCore.QRectF) # mapped rectangle mouse cursor selection sigCrosshairPosChanged = QtCore.Signal(QtCore.QPointF) sigCrosshairDraggedPosChanged = QtCore.Signal(QtCore.QPointF) def __init__(self, *args, **kwargs): kwargs['viewBox'] = ScanViewBox() # Use custom pg.ViewBox subclass super().__init__(*args, **kwargs) self.getViewBox().sigMouseAreaSelected.connect(self.sigMouseAreaSelected) self._min_crosshair_factor = 0.02 self._crosshair_size = (0, 0) self._crosshair_range = None self.getViewBox().sigRangeChanged.connect(self._constraint_crosshair_size) self.crosshair = ROI((0, 0), (0, 0), pen={'color': '#00ff00', 'width': 1}) self.hline = InfiniteLine(pos=0, angle=0, movable=True, pen={'color': '#00ff00', 'width': 1}, hoverPen={'color': '#ffff00', 'width': 1}) self.vline = InfiniteLine(pos=0, angle=90, movable=True, pen={'color': '#00ff00', 'width': 1}, hoverPen={'color': '#ffff00', 'width': 1}) self.vline.sigDragged.connect(self._update_pos_from_line) self.hline.sigDragged.connect(self._update_pos_from_line) self.crosshair.sigRegionChanged.connect(self._update_pos_from_roi) self.sigCrosshairDraggedPosChanged.connect(self.sigCrosshairPosChanged) @property def crosshair_enabled(self): items = self.items() return (self.vline in items) and (self.hline in items) and (self.crosshair in items) @property def crosshair_movable(self): return bool(self.crosshair.translatable) @property def crosshair_position(self): pos = self.vline.pos() pos[1] = self.hline.pos()[1] return tuple(pos) @property def crosshair_size(self): return tuple(self._crosshair_size) @property def crosshair_min_size_factor(self): return float(self._min_crosshair_factor) @property def crosshair_range(self): if self._crosshair_range is None: return None return tuple(self._crosshair_range) @property def selection_enabled(self): return bool(self.getViewBox().rectangle_selection) @property def zoom_by_selection_enabled(self): return bool(self.getViewBox().zoom_by_selection) def toggle_selection(self, enable): """ De-/Activate the rectangular rubber band selection tool. If active you can select a rectangular region within the ViewBox by dragging the mouse with the left button. Each selection rectangle in real-world data coordinates will be emitted by sigMouseAreaSelected. By using activate_zoom_by_selection you can optionally de-/activate zooming in on the selection. @param bool enable: Toggle selection on (True) or off (False) """ return self.getViewBox().toggle_selection(enable) def toggle_zoom_by_selection(self, enable): """ De-/Activate automatic zooming into a selection. See also: toggle_selection @param bool enable: Toggle zoom upon selection on (True) or off (False) """ return self.getViewBox().toggle_zoom_by_selection(enable) def _update_pos_from_line(self, obj): """ Called each time the position of the InfiniteLines has been changed by a user drag. Causes the crosshair rectangle to follow the lines. """ if obj not in (self.hline, self.vline): return pos = self.vline.pos() pos[1] = self.hline.pos()[1] size = self.crosshair.size() self.crosshair.blockSignals(True) self.crosshair.setPos((pos[0] - size[0] / 2, pos[1] - size[1] / 2)) self.crosshair.blockSignals(False) self.sigCrosshairDraggedPosChanged.emit(QtCore.QPointF(pos[0], pos[1])) return def _update_pos_from_roi(self, obj): """ Called each time the position of the rectangular ROI has been changed by a user drag. Causes the InfiniteLines to follow the ROI. """ if obj is not self.crosshair: return pos = self.crosshair.pos() size = self.crosshair.size() pos[0] += size[0] / 2 pos[1] += size[1] / 2 self.vline.setPos(pos[0]) self.hline.setPos(pos[1]) self.sigCrosshairDraggedPosChanged.emit(QtCore.QPointF(pos[0], pos[1])) return def toggle_crosshair(self, enable, movable=True): """ Disable/Enable the crosshair within the PlotWidget. Optionally also toggle if it can be dragged by the user. @param bool enable: enable crosshair (True), disable crosshair (False) @param bool movable: enable user drag (True), disable user drag (False) """ if not isinstance(enable, bool): raise TypeError('Positional argument "enable" must be bool type.') if not isinstance(movable, bool): raise TypeError('Optional argument "movable" must be bool type.') self.toggle_crosshair_movable(movable) is_enabled = self.crosshair_enabled if enable and not is_enabled: self.addItem(self.vline) self.addItem(self.hline) self.addItem(self.crosshair) elif not enable and is_enabled: self.removeItem(self.vline) self.removeItem(self.hline) self.removeItem(self.crosshair) return def toggle_crosshair_movable(self, enable): """ Toggle if the crosshair can be dragged by the user. @param bool enable: enable (True), disable (False) """ self.crosshair.translatable = bool(enable) self.vline.setMovable(enable) self.hline.setMovable(enable) return def set_crosshair_pos(self, pos): """ Set the crosshair center to the given coordinates. @param QPointF|float[2] pos: (x,y) position of the crosshair """ try: pos = tuple(pos) except TypeError: pos = (pos.x(), pos.y()) size = self.crosshair.size() self.crosshair.blockSignals(True) self.vline.blockSignals(True) self.hline.blockSignals(True) self.crosshair.setPos(pos[0] - size[0] / 2, pos[1] - size[1] / 2) self.vline.setPos(pos[0]) self.hline.setPos(pos[1]) self.crosshair.blockSignals(False) self.vline.blockSignals(False) self.hline.blockSignals(False) self.sigCrosshairPosChanged.emit(QtCore.QPointF(*pos)) return def set_crosshair_size(self, size, force_default=True): """ Set the default size of the crosshair rectangle (x, y) and update the display. @param QSize|float[2] size: the (x,y) size of the crosshair rectangle @param bool force_default: Set default crosshair size and enforce minimal size (True). Enforce displayed crosshair size while keeping default size untouched (False). """ try: size = tuple(size) except TypeError: size = (size.width(), size.height()) if force_default: if size[0] <= 0 and size[1] <= 0: self._crosshair_size = (0, 0) else: self._crosshair_size = size # Check if actually displayed size needs to be adjusted due to minimal size size = self._get_corrected_crosshair_size(size) pos = self.vline.pos() pos[1] = self.hline.pos()[1] - size[1] / 2 pos[0] -= size[0] / 2 if self._crosshair_range: crange = self._crosshair_range self.crosshair.maxBounds = QtCore.QRectF(crange[0][0] - size[0] / 2, crange[1][0] - size[1] / 2, crange[0][1] - crange[0][0] + size[0], crange[1][1] - crange[1][0] + size[1]) self.crosshair.blockSignals(True) self.crosshair.setSize(size) self.crosshair.setPos(pos) self.crosshair.blockSignals(False) return def set_crosshair_min_size_factor(self, factor): """ Sets the minimum crosshair size factor. This will determine the minimum size of the smallest edge of the crosshair center rectangle. This minimum size is calculated by taking the smallest visible axis of the ViewBox and multiplying it with the scale factor set by this method. The crosshair rectangle will be then scaled accordingly if the set crosshair size is smaller than this minimal size. @param float factor: The scale factor to set. If <= 0 no minimal crosshair size enforced. """ if factor <= 0: self._min_crosshair_factor = 0 elif factor <= 1: self._min_crosshair_factor = float(factor) else: raise ValueError('Crosshair min size factor must be a value <= 1.') return def set_crosshair_range(self, new_range): """ Sets a range boundary for the crosshair position. @param float[2][2] new_range: two min-max range value tuples (for x and y axis). If None set unlimited ranges. """ if new_range is None: self.vline.setBounds([None, None]) self.hline.setBounds([None, None]) self.crosshair.maxBounds = None else: self.vline.setBounds(new_range[0]) self.hline.setBounds(new_range[1]) size = self.crosshair.size() pos = self.crosshair_position self.crosshair.maxBounds = QtCore.QRectF(new_range[0][0] - size[0] / 2, new_range[1][0] - size[1] / 2, new_range[0][1] - new_range[0][0] + size[0], new_range[1][1] - new_range[1][0] + size[1]) self.crosshair.setPos(pos[0] - size[0] / 2, pos[1] - size[1] / 2) self._crosshair_range = new_range return def set_crosshair_pen(self, pen): """ Sets the pyqtgraph compatible pen to be used for drawing the crosshair lines. @param pen: pyqtgraph compatible pen to use """ self.crosshair.setPen(pen) self.vline.setPen(pen) self.hline.setPen(pen) return def _constraint_crosshair_size(self): if self._min_crosshair_factor == 0: return if self._crosshair_size[0] == 0 or self._crosshair_size[1] == 0: return corr_size = self._get_corrected_crosshair_size(self._crosshair_size) if corr_size != tuple(self.crosshair.size()): self.set_crosshair_size(corr_size, force_default=False) return def _get_corrected_crosshair_size(self, size): try: size = tuple(size) except TypeError: size = (size.width(), size.height()) min_size = min(size) if min_size == 0: return size vb_size = self.getViewBox().viewRect().size() short_index = int(vb_size.width() > vb_size.height()) min_vb_size = vb_size.width() if short_index == 0 else vb_size.height() min_vb_size *= self._min_crosshair_factor if min_size < min_vb_size: scale_factor = min_vb_size / min_size size = (size[0] * scale_factor, size[1] * scale_factor) return size