class GuiTopology(QObject): """ Expose Topology attributes to QML """ def __init__(self, preset, parent=None): super().__init__(parent) self.preset = preset self.load(preset) presets.topo_changed.connect(self.load) def load(self, preset): if self.preset != preset: return self.topology = Topology(preset) self._nodes_dict = { key: GuiNodes(key, nodes) for key, nodes in self.topology.nodes.items() } self._nodes = list(self._nodes_dict.values()) def presetName(self): return self.topology.preset_name def nodes(self): return self._nodes def nodes_dict(self): return self._nodes_dict nodes_changed = Signal() nodes = Property('QVariant', nodes, notify=nodes_changed) nodes_dict_changed = Signal() nodes_dict = Property('QVariant', nodes_dict, notify=nodes_dict_changed) presetName_changed = Signal() presetName = Property(str, presetName, notify=presetName_changed)
class GuiPositions(QObject): def __init__(self, position, parent=None): super().__init__(parent) self.position = position #self._x = [float(n) for n in self.position.x] #self._y = [float(n) for n in self.position.y] def x(self): return [float(n) for n in self.position.x] def y(self): return [float(n) for n in self.position.y] def angle(self): return self.position.angle def absolute_scale(self): return self.position.absolute_scale def translation(self): return self.position.translation def rotate(self, angle_deg): self.position.rotate(angle_deg) self.angle_changed.emit() self.x_changed.emit() self.y_changed.emit() def translate(self, x, y): self.position.translate(x, y) self.translation_changed.emit() self.x_changed.emit() self.y_changed.emit() def scale(self, scale_factor): self.position.scale(scale_factor) self.absolute_scale_changed.emit() self.x_changed.emit() self.y_changed.emit() x_changed = Signal() x = Property('QVariant', x, notify=x_changed) y_changed = Signal() y = Property('QVariant', y, notify=y_changed) angle_changed = Signal() angle = Property('QVariant', angle, notify=angle_changed) absolute_scale_changed = Signal() absolute_scale = Property('QVariant', absolute_scale, notify=absolute_scale_changed) translation_changed = Signal() translation = Property('QVariant', translation, notify=translation_changed)
class ColorButton(QPushButton): """ Color choosing push button """ colorChanged = Signal(QColor) def __init__(self, parent=None): QPushButton.__init__(self, parent) self.setFixedSize(20, 20) self.setIconSize(QSize(12, 12)) self.clicked.connect(self.choose_color) self._color = QColor() def choose_color(self): color = QColorDialog.getColor(self._color, self.parentWidget(), 'Select Color', QColorDialog.ShowAlphaChannel) if color.isValid(): self.set_color(color) def get_color(self): return self._color @Slot(QColor) def set_color(self, color): if color != self._color: self._color = color self.colorChanged.emit(self._color) pixmap = QPixmap(self.iconSize()) pixmap.fill(color) self.setIcon(QIcon(pixmap)) color = Property("QColor", get_color, set_color)
class HalLedWidget(LEDWidget): # one to rule them all hal_status = HALStatus() def __init__(self, parent=None): super(HalLedWidget, self).__init__(parent) self._hal_pin = '' def getHalPin(self): return self._hal_pin @Slot(str) def setHalPin(self, hal_pin): self._hal_pin = hal_pin try: pin = self.hal_status.getHALPin(hal_pin) except ValueError as e: log.warning(e) return pin.valueChanged[bool].connect(self.setState) self.setState(pin.getValue()) log.debug("HAL LED connected to yellow<{}>".format(hal_pin)) hal_pin_name = Property(str, getHalPin, setHalPin)
class GuiNodes(QObject): def __init__(self, key, nodes, parent=None): super().__init__(parent) self.nodes = nodes self._positions = [ GuiPositions(positions) for positions in self.nodes.positions ] self._node_type = key def positions(self): return self._positions def nodeType(self): return self._node_type positions_changed = Signal() positions = Property('QVariant', positions, notify=positions_changed) node_type_changed = Signal() nodeType = Property(str, nodeType, notify=node_type_changed)
class TyphosDesignerMixin(pydm.widgets.base.PyDMWidget): """ A mixin class used to display Typhos widgets in the Qt designer. """ # Unused properties that we don't want visible in designer alarmSensitiveBorder = Property(bool, designable=False) alarmSensitiveContent = Property(bool, designable=False) precisionFromPV = Property(bool, designable=False) precision = Property(int, designable=False) showUnits = Property(bool, designable=False) @Property(str) def channel(self): """The channel address to use for this widget""" if self._channel: return str(self._channel) return None @channel.setter def channel(self, value): if self._channel != value: # Remove old connection if self._channels: self._channels.clear() for channel in self._channels: if hasattr(channel, 'disconnect'): channel.disconnect() # Load new channel self._channel = str(value) channel = HappiChannel(address=self._channel, tx_slot=self._tx) self._channels = [channel] # Connect the channel to the HappiPlugin if hasattr(channel, 'connect'): channel.connect() @Slot(object) def _tx(self, value): """Receive information from happi channel""" self.add_device(value['obj'])
class QDoubleScrollBar(QScrollBar): """A QScrollBar that handles float values.""" rangeChanged = Signal(float, float) sliderMoved = Signal(float) valueChanged = Signal(float) def __init__(self, orientation=Qt.Horizontal, parent=None): """Init.""" self._decimals = 0 self._scale = 1 super(QDoubleScrollBar, self).__init__(orientation, parent) super().rangeChanged.connect(self._intercept_rangeChanged) super().sliderMoved.connect(self._intercept_sliderMoved) super().valueChanged.connect(self._intercept_valueChanged) menu = QMenu(self) ac = menu.addAction('Set Single Step') ac.triggered.connect(self.dialogSingleStep) ac = menu.addAction('Set Page Step') ac.triggered.connect(self.dialogPageStep) menu.addSeparator() ac = menu.addAction("Left edge") ac.triggered.connect(lambda: self.triggerAction(self.SliderToMinimum)) ac = menu.addAction("Right edge") ac.triggered.connect(lambda: self.triggerAction(self.SliderToMaximum)) self.contextMenu = menu @Slot(bool) def dialogSingleStep(self, value): """Show dialog to set singleStep.""" mini = 1/self._scale maxi = (self.maximum - self.minimum)/10 d, okPressed = QInputDialog.getDouble(self, "Single Step", "Single Step:", self.singleStep, mini, maxi, self._decimals) if okPressed: self.setSingleStep(d) def dialogPageStep(self, value): """Show dialog to set pageStep.""" mini = 10/self._scale maxi = (self.maximum - self.minimum) d, okPressed = QInputDialog.getDouble(self, "Page Step", "Page Step:", self.pageStep, mini, maxi, self._decimals) if okPressed: self.setPageStep(d) def contextMenuEvent(self, ev): """Show context menu.""" self.contextMenu.exec_(ev.globalPos()) def getDecimals(self): """Return decimals.""" return self._decimals def setDecimals(self, value): """Set decimals.""" mini = self.getMinimum() maxi = self.getMaximum() sgstep = self.getSingleStep() pgstep = self.getPageStep() val = self.getValue() slpos = self.getSliderPosition() self._decimals = value self._scale = 10**value self.setMinimum(mini) self.setMaximum(maxi) self.setSingleStep(sgstep) self.setPageStep(pgstep) self.setValue(val) self.setSliderPosition(slpos) decimals = Property(int, getDecimals, setDecimals) def getMinimum(self): """Return minimum value.""" return super().minimum()/self._scale def setMinimum(self, value): """Set minimum value.""" if _np.isnan(value): value = 0 try: mini = round(value*self._scale) mini = min(mini, 2**31-1) mini = max(-2**31, mini) super().setMinimum(mini) except (OverflowError, ValueError) as err: logging.warning(str(err), '(value=' + str(value) + ')') minimum = Property(float, getMinimum, setMinimum) def getMaximum(self): """Return maximum value.""" return super().maximum()/self._scale def setMaximum(self, value): """Set maximum value.""" if _np.isnan(value): value = 0 try: maxi = round(value*self._scale) maxi = min(maxi, 2**31-1) maxi = max(-2**31, maxi) super().setMaximum(maxi) except (OverflowError, ValueError) as err: logging.warning(str(err), '(value=' + str(value) + ')') maximum = Property(float, getMaximum, setMaximum) def getSingleStep(self): """Get single step.""" return super().singleStep()/self._scale def setSingleStep(self, value): """Set single step.""" val = round(value*self._scale) rang = super().maximum() - super().minimum() if not val: super().setSingleStep(1) elif val > round(rang/10): super().setSingleStep(round(rang/10)) else: super().setSingleStep(val) singleStep = Property(float, getSingleStep, setSingleStep) def getPageStep(self): """Get page step.""" return super().pageStep()/self._scale def setPageStep(self, value): """Set page step.""" val = round(value*self._scale) rang = super().maximum() - super().minimum() if val < 10: super().setPageStep(10) elif val > round(rang): super().setPageStep(round(rang)) else: super().setPageStep(val) pageStep = Property(float, getPageStep, setPageStep) def getValue(self): """Get value.""" return super().value()/self._scale @Slot(float) def setValue(self, value): """Set value.""" if value is None: return try: val = round(value*self._scale) val = min(val, 2**31-1) val = max(-2**31, val) super().setValue(val) except (OverflowError, ValueError) as err: logging.warning(str(err), '(value=' + str(value) + ')') value = Property(float, getValue, setValue) def getSliderPosition(self): """Get slider position.""" return super().sliderPosition()/self._scale def setSliderPosition(self, value): """Set slider position.""" pos = round(value*self._scale) pos = min(pos, 2**31-1) pos = max(-2**31, pos) super().setSliderPosition(pos) sliderPosition = Property(float, getSliderPosition, setSliderPosition) def keyPressEvent(self, event): """Reimplement keyPressEvent.""" singlestep = self.getSingleStep() pagestep = self.getPageStep() ctrl_hold = self.app.queryKeyboardModifiers() == Qt.ControlModifier if ctrl_hold and (event.key() == Qt.Key_Left): self.setSingleStep(10*singlestep) self.setPageStep(10*pagestep) self._show_step_tooltip() elif ctrl_hold and (event.key() == Qt.Key_Right): self.setSingleStep(0.1*singlestep) self.setPageStep(0.1*pagestep) self._show_step_tooltip() else: super().keyPressEvent(event) def _show_step_tooltip(self): QToolTip.showText( self.mapToGlobal( QPoint(self.x()+self.width()/2, self.y()-2*self.height())), 'Single step: '+str(self.singleStep) + '\nPage step: '+str(self.pageStep), self, self.rect(), 1000) @Slot(float, float) def setRange(self, mini, maxi): """Set range.""" mini = max(-2**31, round(mini/self._scale)) maxi = min(round(maxi*self._scale), 2**31-1) super().setRange(mini, maxi) @Slot(int, int) def _intercept_rangeChanged(self, mini, maxi): self.rangeChanged.emit(mini/self._scale, maxi/self._scale) @Slot(int) def _intercept_sliderMoved(self, value): self.sliderMoved.emit(value/self._scale) @Slot(int) def _intercept_valueChanged(self, value): self.valueChanged.emit(value/self._scale)
class PyDMDrawingPolyline(PyDMDrawing): """ A widget with a multi-segment, piecewise-linear line drawn in it. This class inherits from PyDMDrawing. Parameters ---------- parent : QWidget The parent widget for the Label init_channel : str, optional The channel to be used by the widget. """ def __init__(self, parent=None, init_channel=None): super(PyDMDrawingPolyline, self).__init__(parent, init_channel) self.penStyle = Qt.SolidLine self.penWidth = 1 self._points = [] def draw_item(self, painter): """ Draws the segmented line after setting up the canvas with a call to ```PyDMDrawing.draw_item```. """ super(PyDMDrawingPolyline, self).draw_item(painter) x, y, w, h = self.get_bounds() def p2d(pt): "convert point to drawing coordinates" # drawing coordinates are centered: (0,0) is in center # our points are absolute: (0,0) is upper-left corner u, v = map(int, pt.split(",")) return QPointF(u+x, v+y) if len(self._points) > 1: for i, p1 in enumerate(self._points[:-1]): painter.drawLine(p2d(p1), p2d(self._points[i+1])) def getPoints(self): return self._points def _validator_(self, value): """ ensure that `value` has correct form Parameters ---------- value : [str] List of strings representing ordered pairs of integer coordinates. Each ordered pair is comma-separated (although white-space separated is acceptable as input). Returns ---------- verified : [str] List of strings in standard format """ def isinteger(value): value = value.strip() try: float(value) return True except: return False verified = [] for i, pt in enumerate(value): point = pt.split(",") if len(point) != 2: point = pt.split() # tolerant of space-separated if len(point) != 2: emsg = "polyline point %d must be two values, comma-separated, received '%s'" % (i+1, pt) logger.exception(emsg) return if not isinteger(point[0]): emsg = "polyline point %d content must be integer, received '%s'" % (i+1, point[0]) logger.exception(emsg) return if not isinteger(point[1]): emsg = "polyline point %d content must be integer, received '%s'" % (i+1, point[1]) logger.exception(emsg) return verified.append(", ".join(point)) return verified def setPoints(self, value): if len(value) < 2: emsg = "Must have two or more points" logger.exception(emsg) return verified = self._validator_(value) if verified is not None: self._points = verified self.update() def resetPoints(self): self._points = [] self.update() points = Property("QStringList", getPoints, setPoints, resetPoints)
class GcodeEditor(EditorBase, QObject): ARROW_MARKER_NUM = 8 def __init__(self, parent=None): super(GcodeEditor, self).__init__(parent) self.filename = "" self._last_filename = None self.auto_show_mdi = True self.last_line = None # self.setEolVisibility(True) self.is_editor = False self.dialog = FindReplaceDialog(parent=self) # QSS Hack self.backgroundcolor = '' self.marginbackgroundcolor = '' @Slot(bool) def setEditable(self, state): if state: self.setReadOnly(False) else: self.setReadOnly(True) @Slot(str) def setFilename(self, path): self.filename = path @Slot() def save(self): save_file = QFile(self.filename) result = save_file.open(QFile.WriteOnly) if result: save_stream = QTextStream(save_file) save_stream << self.text() save_file.close() @Slot() def saveAs(self): file_name = self.save_as_dialog(self.filename) if file_name is False: return original_file = QFileInfo(self.filename) path = original_file.path() new_absolute_path = os.path.join(path, file_name) new_file = QFile(new_absolute_path) result = new_file.open(QFile.WriteOnly) if result: save_stream = QTextStream(new_file) save_stream << self.text() new_file.close() @Slot() def find_replace(self): self.dialog.show() def search_text(self, find_text, highlight_all): from_start = False if find_text != "": self.text_search(find_text, from_start, highlight_all) def replace_text(self, find_text, replace_text): from_start = False if find_text != "" and replace_text != "": self.text_replace(find_text, replace_text, from_start) def replace_all_text(self, find_text, replace_text): from_start = True if find_text != "" and replace_text != "": self.text_replace_all(find_text, find_text, from_start) @Property(bool) def is_editor(self): return self._is_editor @is_editor.setter def is_editor(self, enabled): self._is_editor = enabled if not self._is_editor: STATUS.file.notify(self.load_program) STATUS.motion_line.onValueChanged(self.highlight_line) # STATUS.connect('line-changed', self.highlight_line) # if self.idle_line_reset: # STATUS.connect('interp_idle', lambda w: self.set_line_number(None, 0)) @Property(str) def backgroundcolor(self): """Property to set the background color of the GCodeEditor (str). sets the background color of the GCodeEditor """ return self._backgroundcolor @backgroundcolor.setter def backgroundcolor(self, color): self._backgroundcolor = color self.set_background_color(color) @Property(str) def marginbackgroundcolor(self): """Property to set the background color of the GCodeEditor margin (str). sets the background color of the GCodeEditor margin """ return self._marginbackgroundcolor @marginbackgroundcolor.setter def marginbackgroundcolor(self, color): self._marginbackgroundcolor = color self.set_margin_background_color(color) def load_program(self, fname=None): if fname is None: fname = self._last_filename else: self._last_filename = fname self.load_text(fname) # self.zoomTo(6) self.setCursorPosition(0, 0) # when switching from MDI to AUTO we need to reload the # last (linuxcnc loaded) program. def reload_last(self): self.load_text(STATUS.old['file']) self.setCursorPosition(0, 0) # With the auto_show__mdi option, MDI history is shown def load_mdi(self): self.load_text(INFO.MDI_HISTORY_PATH) self._last_filename = INFO.MDI_HISTORY_PATH # print 'font point size', self.font().pointSize() # self.zoomTo(10) # print 'font point size', self.font().pointSize() self.setCursorPosition(self.lines(), 0) # With the auto_show__mdi option, MDI history is shown def load_manual(self): if STATUS.is_man_mode(): self.load_text(INFO.MACHINE_LOG_HISTORY_PATH) self.setCursorPosition(self.lines(), 0) def load_text(self, fname): try: fp = os.path.expanduser(fname) self.setText(open(fp).read()) except: LOG.error('File path is not valid: {}'.format(fname)) self.setText('') return self.last_line = None self.ensureCursorVisible() self.SendScintilla(QsciScintilla.SCI_VERTICALCENTRECARET) def highlight_line(self, line): # if STATUS.is_auto_running(): # if not STATUS.old['file'] == self._last_filename: # LOG.debug('should reload the display') # self.load_text(STATUS.old['file']) # self._last_filename = STATUS.old['file'] self.markerAdd(line, self.ARROW_MARKER_NUM) if self.last_line: self.markerDelete(self.last_line, self.ARROW_MARKER_NUM) self.setCursorPosition(line, 0) self.ensureCursorVisible() self.SendScintilla(QsciScintilla.SCI_VERTICALCENTRECARET) self.last_line = line def set_line_number(self, line): pass def line_changed(self, line, index): # LOG.debug('Line changed: {}'.format(STATUS.is_auto_mode())) self.line_text = str(self.text(line)).strip() self.line = line if STATUS.is_mdi_mode() and STATUS.is_auto_running() is False: STATUS.emit('mdi-line-selected', self.line_text, self._last_filename) def select_lineup(self): line, col = self.getCursorPosition() LOG.debug(line) self.setCursorPosition(line - 1, 0) self.highlight_line(line - 1) def select_linedown(self): line, col = self.getCursorPosition() LOG.debug(line) self.setCursorPosition(line + 1, 0) self.highlight_line(line + 1) # designer recognized getter/setters # auto_show_mdi status def set_auto_show_mdi(self, data): self.auto_show_mdi = data def get_auto_show_mdi(self): return self.auto_show_mdi def reset_auto_show_mdi(self): self.auto_show_mdi = True auto_show_mdi_status = Property(bool, get_auto_show_mdi, set_auto_show_mdi, reset_auto_show_mdi) # simple input dialog for save as def save_as_dialog(self, filename): text, ok_pressed = QInputDialog.getText(self, "Save as", "New name:", QLineEdit.Normal, filename) if ok_pressed and text != '': return text else: return False
class GrabCutInstance(QObject): GRAB_CUT_NUM_ITER = 5 COLOR_OBJ_SURE = bgr(40, 250, 10, 100) COLOR_OBJ_GUESS = bgr(200, 200, 20, 50) COLOR_OBJ_CONTOUR = bgr(0, 255, 0, 200) COLOR_BGD_GUESS = bgr(120, 40, 20, 0) COLOR_BGD_SURE = bgr(250, 40, 10, 100) COLOR_TABLE = np.array( [COLOR_BGD_SURE, COLOR_OBJ_SURE, COLOR_BGD_GUESS, COLOR_OBJ_GUESS]) ALPHA_CONTOUR = 255 ALPHA_CLASS_COLOR = 150 MORPH_KERNEL = np.ones((3, 3), np.uint8) def __init__(self, backend, instance_id, semantic_class, photo, crop_rect, roi_rect, use_grab_cut=True, depth_index=None): super().__init__() self.backend = backend self.id = instance_id self.semantic_class = semantic_class self.photo = photo self.crop_rect = crop_rect self.roi_rect = roi_rect self.crop_tl = crop_rect[0] self.crop_br = crop_rect[1] self.roi_tl = roi_rect[0] - self.crop_tl self.roi_br = roi_rect[1] - self.crop_tl self.photo_crop = self.photo[self.crop_tl[1]:self.crop_br[1], self.crop_tl[0]:self.crop_br[0]] self.depth_index = depth_index or backend.depth_index_new() self.use_grab_cut = use_grab_cut self.update_qt_info() def grab_cut_init(self, existing_instance_mask_global=None): self.grab_cut_state = np.zeros((2, 65), np.float64) self.grab_cut_mask = np.full(self.photo_crop.shape[:2], cv2.GC_PR_BGD, dtype=np.uint8) # sometimes grab cut throws an exception because it finds no foreground in the whole roi # we help it then by marking the central pixel as foreground def set_center_pixel_to_foreground(): sh_c = np.array(self.grab_cut_mask.shape) // 2 sh_l = sh_c - 2 sh_r = sh_c + 2 self.grab_cut_mask[sh_l[0]:sh_r[0], sh_l[1]:sh_r[1]] = cv2.GC_FGD #self.grab_cut_mask[sh[0]//2, sh[1]//2] = cv2.GC_FGD # self.grab_cut_mask[0, 0] = cv2.GC_BGD #print('gc mask bincount', np.bincount(self.grab_cut_mask.reshape(-1))) def gc_init(mode=cv2.GC_INIT_WITH_RECT): cv2.grabCut( self.photo_crop, self.grab_cut_mask, tuple( np.concatenate([self.roi_tl, self.roi_br - self.roi_tl], axis=0)), self.grab_cut_state[0:1], self.grab_cut_state[1:2], self.GRAB_CUT_NUM_ITER, mode, ) if self.use_grab_cut: try: gc_init() except cv2.error: log.warning( 'GrabCut failed on initialization - retrying with center pixel marked' ) set_center_pixel_to_foreground() gc_init(mode=cv2.GC_INIT_WITH_RECT | cv2.GC_INIT_WITH_MASK) # exclude previously existing instances if existing_instance_mask_global is not None: # we do not do it in the single init step, because if we use the "init with mask" mode # grab-cut expects to have BOTH negative and positive samples and crashes on an assert # - but we only have negative samples # therefore, we will now perform another step but with the negative samples existing_instance_mask_crop = existing_instance_mask_global[ self.crop_tl[1]:self.crop_br[1], self.crop_tl[0]:self.crop_br[0]] if np.any(existing_instance_mask_crop): self.grab_cut_mask[np.where( existing_instance_mask_crop)] = cv2.GC_BGD log.debug( 'Applying mask of existing objects to the new instance, label counts: {nonzero} {bc}' .format( nonzero=np.count_nonzero( existing_instance_mask_crop), bc=np.bincount(self.grab_cut_mask.reshape(-1)), )) try: self.grab_cut_update() except cv2.error: log.warning( 'GrabCut failed after applying existing object mask - retrying with center pixel marked' ) set_center_pixel_to_foreground() self.grab_cut_update() self.update_mask() def grab_cut_update(self): if self.use_grab_cut: cv2.grabCut( self.photo_crop, self.grab_cut_mask, None, self.grab_cut_state[0:1], self.grab_cut_state[1:2], self.GRAB_CUT_NUM_ITER, cv2.GC_INIT_WITH_MASK, ) self.update_mask() def paint_circle(self, label, center_pt): label_value = [cv2.GC_BGD, cv2.GC_FGD][label] center_pt = center_pt - self.crop_tl cv2.circle(self.grab_cut_mask, tuple(center_pt), 5, label_value, -1) self.update_mask() def paint_polygon(self, label, points): label_value = [cv2.GC_BGD, cv2.GC_FGD][label] points_in_crop = points - self.crop_tl points_in_crop_int = np.rint(points_in_crop).astype(np.int32) cv2.drawContours(self.grab_cut_mask, [points_in_crop_int], 0, label_value, -1) self.update_mask() def update_mask(self): self.mask = (self.grab_cut_mask == cv2.GC_FGD) | (self.grab_cut_mask == cv2.GC_PR_FGD) erosion = cv2.erode(self.mask.astype(np.uint8), self.MORPH_KERNEL, iterations=1).astype(np.bool) self.contour_mask = self.mask & ~erosion self.contour_where = np.where(self.contour_mask) def draw_overlay_edit_interface(self, overlay): overlay_crop = overlay[self.crop_tl[1]:self.crop_br[1], self.crop_tl[0]:self.crop_br[0]] overlay_crop[:] = self.COLOR_TABLE[self.grab_cut_mask.reshape( -1)].reshape(overlay_crop.shape) overlay_crop[self.contour_where] = self.COLOR_OBJ_CONTOUR def draw_overlay_contour(self, overlay): overlay_crop = overlay[self.crop_tl[1]:self.crop_br[1], self.crop_tl[0]:self.crop_br[0]] class_color_bgr = self.semantic_class.color[::-1] overlay_crop[self.mask] = np.concatenate( [class_color_bgr, [self.ALPHA_CLASS_COLOR]], axis=0) overlay_crop[self.contour_where] = np.concatenate( [class_color_bgr, [self.ALPHA_CONTOUR]], axis=0) def draw_mask(self, global_mask, label=None): if label is None: label = self.semantic_class.id mask_crop = global_mask[self.crop_tl[1]:self.crop_br[1], self.crop_tl[0]:self.crop_br[0]] mask_crop[self.mask] = label # def assign_reshape(): # overlay_crop[:] = self.COLOR_TABLE[self.grab_cut_mask.reshape(-1)].reshape(overlay_crop.shape) # # def assign_equal(): # overlay_crop[self.grab_cut_mask == cv2.GC_FGD] = self.COLOR_OBJ_SURE # overlay_crop[self.grab_cut_mask == cv2.GC_PR_FGD] = self.COLOR_OBJ_GUESS # overlay_crop[self.grab_cut_mask == cv2.GC_PR_BGD] = self.COLOR_BGD_GUESS # overlay_crop[self.grab_cut_mask == cv2.GC_BGD] = self.COLOR_BGD_SURE # # import timeit # # gl = dict( # assign_reshape = assign_reshape, # assign_equal=assign_equal, # ) # n = int(1e4) # print('tm(reshape) ', timeit.timeit('assign_reshape()', globals=gl, number=n)) # print('tm(equal) ', timeit.timeit('assign_equal()', globals=gl, number=n)) # #tm(reshape) 10.847654940000211 # #tm(equal) 18.054724517001887 def contains_point(self, pt) -> bool: pt_int = np.rint(pt).astype(np.int32) if np.all(pt_int >= self.crop_tl) and np.all(pt_int < self.crop_br): #log.debug(f'point {pt_int} is inside of bbox {self.crop_tl} {self.crop_br} - check mask') pt_in_crop = pt_int - self.crop_tl return self.mask[pt_in_crop[1], pt_in_crop[0]] else: #log.debug(f'point {pt_int} is outside of bbox {self.crop_tl} {self.crop_br}') return False def to_dict(self): return dict( id=self.id, cls=self.semantic_class.id, crop_rect=self.crop_rect.tolist(), roi_rect=self.roi_rect.tolist(), depth_index=self.depth_index, use_grab_cut=self.use_grab_cut, ) def save_to_dir(self, dir_path): imwrite(dir_path / f'instance_{self.id:03d}_gc_mask.png', self.grab_cut_mask) np.save(dir_path / f'instance_{self.id:03d}_gc_state.npy', self.grab_cut_state) def load_from_dir(self, dir_path): self.grab_cut_mask = imread(dir_path / f'instance_{self.id:03d}_gc_mask.png') self.grab_cut_state = np.load(dir_path / f'instance_{self.id:03d}_gc_state.npy') self.update_mask() @staticmethod def from_dict(backend, saved_info, config, photo): inst = GrabCutInstance( backend, saved_info['id'], config.classes_by_id[saved_info['cls']], photo, np.array(saved_info['crop_rect']), np.array(saved_info['roi_rect']), depth_index=saved_info.get('depth_index'), use_grab_cut=saved_info.get('use_grab_cut', True), ) return inst # self.depth_index += change # log.debug(f'Depth index +{change} is now {self.depth_index}') # self.backend.reindex(self, new_index) # self.update_qt_info() # Expose to Qt infoChanged = Signal() info = Property("QVariant", notify=infoChanged) @info.getter def getInfo(self): return self.qt_info def update_qt_info(self): self.qt_info = dict( id=self.id, name=f'{self.semantic_class.name} {self.depth_index}', cls=self.semantic_class.to_dict(), x=float(self.crop_tl[0] + self.roi_tl[0]), y=float(self.crop_tl[1] + self.roi_tl[1]), width=float(self.roi_br[0] - self.roi_tl[0]), height=float(self.roi_br[1] - self.roi_tl[1]), depth_index=self.depth_index, ) self.infoChanged.emit() deleted = Signal()
class LabelBackend(QObject): @staticmethod def qml_point_to_np(qpoint: QPointF): return np.array(qpoint.toTuple()) @staticmethod def qml_rect_to_np(qrect: QRectF): return np.array([ qrect.topLeft().toTuple(), qrect.bottomRight().toTuple(), ]) def __init__(self): super().__init__() self.instances = [] self.instances_by_id = {} self.image_provider = LabelOverlayImageProvider() self.config = LabelConfig() self.dir_start = '' # Semantic classes def load_config(self, cfg_path): if cfg_path.is_file(): self.config.load_from_path(cfg_path) else: log.error(f'Config path {cfg_path} is not a file') @staticmethod def load_photo(img_path): """ Load image and ensure it is 8bit RGB """ img_data = imread(img_path) # 8 bit img_data = img_data.astype(np.uint8) # ensure that is has 3 channels if img_data.shape.__len__() == 2: # 2D grayscale img_data = np.broadcast_to(img_data[:, :, None], img_data.shape + (3, )) if img_data.shape[2] == 4: #RGBA img_data = img_data[:, :, :3] return img_data @Slot(result=str) def get_image_path(self): return str(self.img_path) def set_image_path(self, img_path): log.info(f'Loading image {img_path}') # Load new image # this may throw if its not an image, in that case we don't apply any changes to variables img_path = Path(img_path) img_data = self.load_photo(img_path) self.img_path = img_path self.photo = img_data self.resolution = np.array(self.photo.shape[:2][::-1]) self.image_provider.init_image(self.resolution) self.overlay_data = self.image_provider.image_view # Clear instances for old_inst in self.instances: old_inst.deleted.emit() self.instances = [] self.instances_by_id = {} # Load state data_dir = self.img_path.with_suffix('.labels') if data_dir.is_dir(): log.info(f'Loading saved state from {data_dir}') self.load(data_dir) self.next_instance_id = int( np.max([0] + [inst.id for inst in self.instances]) + 1) self.instances_by_id = {inst.id: inst for inst in self.instances} self.instance_selected = None self.overlay_refresh_after_selection_change() @Slot(QUrl, result=bool) def set_image(self, img_url: QUrl): try: # this has to finish, we don't want to break UI interaction self.set_image_path(img_url.toLocalFile()) return True except Exception as e: log.exception('Exception in set_image') return False @Slot(int, QPointF) def paint_circle(self, label_to_paint: int, center: QPointF): try: # this has to finish, we don't want to break UI interaction if self.instance_selected: center_pt = np.rint(center.toTuple()).astype(dtype=np.int) self.instance_selected.paint_circle(label_to_paint, center_pt) self.instance_selected.grab_cut_update() self.overlay_refresh_after_edit() else: log.info('paint_circle: no instance is selected') except Exception as e: log.exception('Exception in paint_circle') @Slot(int, QJSValue) def paint_polygon(self, label_to_paint: int, points: QJSValue): try: # this has to finish, we don't want to break UI interaction if self.instance_selected: points = np.array([p.toTuple() for p in points.toVariant()]) self.instance_selected.paint_polygon(label_to_paint, points) self.instance_selected.grab_cut_update() self.overlay_refresh_after_edit() else: log.info('paint_polygon: no instance is selected') except Exception as e: log.exception('Exception in paint_polygon') @Slot(QPointF, result=int) def instance_at_point(self, pt: QPointF): """ Instance id at point -1 means no instance """ try: pt = np.rint(self.qml_point_to_np(pt)).astype(np.int32) for inst in self.instances_by_depthindex(): if inst.contains_point(pt): return inst.id return -1 except Exception as e: log.exception('Exception in instance_at_point') return -1 def overlay_refresh_after_selection_change(self): if self.instance_selected: self.overlay_data[:] = (0, 0, 0, 128) self.instance_selected.draw_overlay_edit_interface( self.overlay_data) else: self.overlay_data[:] = 0 # draw with depth in mind for inst in reversed(self.instances_by_depthindex()): inst.draw_overlay_contour(self.overlay_data) self.overlayUpdated.emit() self.selectedUpdate.emit() def overlay_refresh_after_edit(self): if self.instance_selected: self.instance_selected.draw_overlay_edit_interface( self.overlay_data) self.overlayUpdated.emit() else: log.info( 'overlay_refresh_after_edit but instance_selected is null') def depth_index_new(self): return max( (inst.depth_index for inst in self.instances), default=0) + 1 def instances_by_depthindex(self): instances_by_depthindex = self.instances.copy() instances_by_depthindex.sort(key=attrgetter('depth_index')) return instances_by_depthindex @Slot(int) def select_instance(self, instance_id: int): self.instance_selected = self.instances_by_id.get(instance_id, None) self.overlay_refresh_after_selection_change() @Slot(QRectF, int) def new_instance(self, roi_rect_qt: QRectF, sem_class_id: int): try: # this has to finish, we don't want to break UI interaction roi_rect = np.rint(self.qml_rect_to_np(roi_rect_qt)).astype(np.int) sem_class = self.config.classes_by_id.get(sem_class_id, self.config.classes[0]) margin = 32 crop_rect = np.array([ np.maximum(roi_rect[0] - margin, 0), np.minimum(roi_rect[1] + margin, self.resolution), ]) # calculate area crop_size = crop_rect[1] - crop_rect[0] area = np.prod(crop_size) # bool(...) because the bool_ object returned by np is not json-serializable use_grab_cut = bool(area < MAX_AREA_FOR_GRAB_CUT) if not use_grab_cut: log.info( f'GrabCut is not used for instance, because patch area ({area}) is above {MAX_AREA_FOR_GRAB_CUT}' ) # automatically mark existing instances as excluded from the new instance existing_instance_mask = np.zeros(tuple(self.resolution[::-1]), dtype=np.uint8) for inst in self.instances: inst.draw_mask(existing_instance_mask, 1) instance = GrabCutInstance( self, self.next_instance_id, sem_class, self.photo, crop_rect, roi_rect, use_grab_cut=use_grab_cut, ) self.next_instance_id += 1 instance.grab_cut_init(existing_instance_mask) self.instances.append(instance) self.instances_by_id[instance.id] = instance self.select_instance(instance.id) self.instanceAdded.emit(instance) except Exception as e: log.exception('Exception in new_instance') @Slot(int, int) def set_instance_class(self, instance_id: int, class_id: int): try: # this has to finish, we don't want to break UI interaction inst = self.instances_by_id[instance_id] cls = self.config.classes_by_id[class_id] inst.semantic_class = cls inst.update_qt_info() self.overlay_refresh_after_selection_change() except Exception as e: log.exception('Exception in set_instance_class') @Slot(int) def delete_instance(self, instance_id: int): try: # this has to finish, we don't want to break UI interaction inst = self.instances_by_id[instance_id] if self.instance_selected == inst: self.select_instance(0) del self.instances_by_id[instance_id] self.instances.remove(inst) inst.deleted.emit() self.overlay_refresh_after_selection_change() except Exception as e: log.exception('Exception in delete_instance') @Slot(int, int) def change_instance_depth(self, instance_id: int, change: int): try: # this has to finish, we don't want to break UI interaction instance = self.instances_by_id.get(instance_id) if instance is not None: # -1 because array is 0 indexed but depth is 1-indexed requested_index = max(0, instance.depth_index - 1 + change) instances_by_depthindex = self.instances_by_depthindex() instances_by_depthindex.remove(instance) instances_by_depthindex.insert(requested_index, instance) #log.debug(f'Depth: moving inst {instance.id} to {requested_index}') for i, inst in enumerate(instances_by_depthindex): new_depth_index = i + 1 if new_depth_index != inst.depth_index: #log.debug(f'Inst {inst.id} depth change: {inst.depth_index} -> {new_depth_index}') inst.depth_index = new_depth_index inst.update_qt_info() else: log.warning( 'change_instance_depth with nonexistent instance id {instance_id}' ) except Exception as e: log.exception( f'Exception in change_instance_depth, args: {instance_id}, {change}' ) @Slot(result=bool) def save(self): try: # this has to finish, we don't want to break UI interaction log.info(f'save {self.img_path}') # outputs sem_map = np.zeros(tuple(self.resolution[::-1]), dtype=np.uint8) sem_colorimg = np.zeros(tuple(self.resolution[::-1]) + (3, ), dtype=np.uint8) inst_map = np.zeros(tuple(self.resolution[::-1]), dtype=np.uint8) # draw the instance list, using depth_index as label for inst in reversed(self.instances_by_depthindex()): inst.draw_mask(sem_map) inst.draw_mask(sem_colorimg, inst.semantic_class.color) inst.draw_mask(inst_map, inst.depth_index) out_dir = self.img_path.with_suffix('.labels') out_dir.mkdir(exist_ok=True) imwrite(out_dir / 'labels_semantic.png', sem_map) imwrite(out_dir / 'labels_semantic_color.png', sem_colorimg) imwrite(out_dir / 'labels_instance.png', inst_map) # internal state json_data = dict( instances=[inst.to_dict() for inst in self.instances]) with (out_dir / 'index.json').open('w') as f_out: json.dump(json_data, f_out, indent=' ') for inst in self.instances: inst.save_to_dir(out_dir) return True except Exception as e: log.exception('Exception in save') return False def load(self, in_dir): with (in_dir / 'index.json').open('r') as f_in: json_data = json.load(f_in) self.instances = [ GrabCutInstance.from_dict(self, inst_data, self.config, self.photo) for inst_data in json_data['instances'] ] for inst in self.instances: inst.load_from_dir(in_dir) self.instanceAdded.emit(inst) # Expose to Qt overlayUpdated = Signal() instanceAdded = Signal(QObject) classesUpdated = Signal() classes = Property('QVariant', notify=classesUpdated) @classes.getter def get_classes(self): return self.config.to_simple_objects() @Slot(result='QVariant') def get_instances(self): return self.instances selectedUpdate = Signal() selected = Property(QObject, attrgetter('instance_selected'), notify=selectedUpdate) def set_starting_directory(self, dir_start): self.dir_start = dir_start @Slot(result=str) def get_starting_directory(self): log.info(f'dir start {self.dir_start}') return str(self.dir_start)
class PyDMTimePlot(BasePlot): """ PyDMWaveformPlot is a widget to plot one or more waveforms. Each curve can plot either a Y-axis waveform vs. its indices, or a Y-axis waveform against an X-axis waveform. Parameters ---------- parent : optional The parent of this widget. init_y_channels : list A list of scalar channels to plot vs time. plot_by_timestamps : bool If True, the x-axis shows timestamps as ticks, and those timestamps scroll to the left as time progresses. If False, the x-axis tick marks show time relative to the current time. background: optional The background color for the plot. Accepts any arguments that pyqtgraph.mkColor will accept. """ SynchronousMode = 1 AsynchronousMode = 2 plot_redrawn_signal = Signal(TimePlotCurveItem) def __init__(self, parent=None, init_y_channels=[], plot_by_timestamps=True, background='default'): """ Parameters ---------- parent : Widget The parent widget of the chart. init_y_channels : list A list of scalar channels to plot vs time. plot_by_timestamps : bool If True, the x-axis shows timestamps as ticks, and those timestamps scroll to the left as time progresses. If False, the x-axis tick marks show time relative to the current time. background : str, optional The background color for the plot. Accepts any arguments that pyqtgraph.mkColor will accept. """ self._plot_by_timestamps = plot_by_timestamps self._left_axis = AxisItem("left") if plot_by_timestamps: self._bottom_axis = TimeAxisItem('bottom') else: self.starting_epoch_time = time.time() self._bottom_axis = AxisItem('bottom') super(PyDMTimePlot, self).__init__(parent=parent, background=background, axisItems={ "bottom": self._bottom_axis, "left": self._left_axis }) # Removing the downsampling while PR 763 is not merged at pyqtgraph # Reference: https://github.com/pyqtgraph/pyqtgraph/pull/763 # self.setDownsampling(ds=True, auto=True, mode="mean") if self._plot_by_timestamps: self.plotItem.disableAutoRange(ViewBox.XAxis) self.getViewBox().setMouseEnabled(x=False) else: self.plotItem.setRange(xRange=[DEFAULT_X_MIN, 0], padding=0) self.plotItem.setLimits(xMax=0) self._bufferSize = DEFAULT_BUFFER_SIZE self._time_span = DEFAULT_TIME_SPAN # This is in seconds self._update_interval = DEFAULT_UPDATE_INTERVAL self.update_timer = QTimer(self) self.update_timer.setInterval(self._update_interval) self._update_mode = PyDMTimePlot.SynchronousMode self._needs_redraw = True self.labels = {"left": None, "right": None, "bottom": None} self.units = {"left": None, "right": None, "bottom": None} for channel in init_y_channels: self.addYChannel(channel) def initialize_for_designer(self): # If we are in Qt Designer, don't update the plot continuously. # This function gets called by PyDMTimePlot's designer plugin. self.redraw_timer.setSingleShot(True) def addYChannel(self, y_channel=None, name=None, color=None, lineStyle=None, lineWidth=None, symbol=None, symbolSize=None): """ Adds a new curve to the current plot Parameters ---------- y_channel : str The PV address name : str The name of the curve (usually made the same as the PV address) color : QColor The color for the curve lineStyle : str The line style of the curve, i.e. solid, dash, dot, etc. lineWidth : int How thick the curve line should be symbol : str The symbols as markers along the curve, i.e. circle, square, triangle, star, etc. symbolSize : int How big the symbols should be Returns ------- new_curve : TimePlotCurveItem The newly created curve. """ plot_opts = dict() plot_opts['symbol'] = symbol if symbolSize is not None: plot_opts['symbolSize'] = symbolSize if lineStyle is not None: plot_opts['lineStyle'] = lineStyle if lineWidth is not None: plot_opts['lineWidth'] = lineWidth # Add curve new_curve = TimePlotCurveItem( y_channel, plot_by_timestamps=self._plot_by_timestamps, name=name, color=color, **plot_opts) new_curve.setUpdatesAsynchronously(self.updatesAsynchronously) new_curve.setBufferSize(self._bufferSize) self.update_timer.timeout.connect(new_curve.asyncUpdate) self.addCurve(new_curve, curve_color=color) new_curve.data_changed.connect(self.set_needs_redraw) self.redraw_timer.start() return new_curve def removeYChannel(self, curve): """ Remove a curve from the graph. This also stops update the timer associated with the curve. Parameters ---------- curve : TimePlotCurveItem The curve to be removed. """ self.update_timer.timeout.disconnect(curve.asyncUpdate) self.removeCurve(curve) if len(self._curves) < 1: self.redraw_timer.stop() def removeYChannelAtIndex(self, index): """ Remove a curve from the graph, given its index in the graph's curve list. Parameters ---------- index : int The curve's index from the graph's curve list. """ curve = self._curves[index] self.removeYChannel(curve) @Slot() def set_needs_redraw(self): self._needs_redraw = True @Slot() def redrawPlot(self): """ Redraw the graph """ if not self._needs_redraw: return self.updateXAxis() for curve in self._curves: curve.redrawCurve() self.plot_redrawn_signal.emit(curve) self._needs_redraw = False def updateXAxis(self, update_immediately=False): """ Update the x-axis for every graph redraw. Parameters ---------- update_immediately : bool Update the axis range(s) immediately if True, or defer until the next rendering. """ if len(self._curves) == 0: return if self._plot_by_timestamps: if self._update_mode == PyDMTimePlot.SynchronousMode: maxrange = max([curve.max_x() for curve in self._curves]) else: maxrange = time.time() minrange = maxrange - self._time_span self.plotItem.setXRange(minrange, maxrange, padding=0.0, update=update_immediately) else: diff_time = self.starting_epoch_time - max( [curve.max_x() for curve in self._curves]) if diff_time > DEFAULT_X_MIN: diff_time = DEFAULT_X_MIN self.getViewBox().setLimits(minXRange=diff_time) def clearCurves(self): """ Remove all curves from the graph. """ super(PyDMTimePlot, self).clear() def getCurves(self): """ Dump the current list of curves and each curve's settings into a list of JSON-formatted strings. Returns ------- settings : list A list of JSON-formatted strings, each containing a curve's settings """ return [json.dumps(curve.to_dict()) for curve in self._curves] def setCurves(self, new_list): """ Add a list of curves into the graph. Parameters ---------- new_list : list A list of JSON-formatted strings, each contains a curve and its settings """ try: new_list = [json.loads(str(i)) for i in new_list] except ValueError as e: logger.exception("Error parsing curve json data: {}".format(e)) return self.clearCurves() for d in new_list: color = d.get('color') if color: color = QColor(color) self.addYChannel(d['channel'], name=d.get('name'), color=color, lineStyle=d.get('lineStyle'), lineWidth=d.get('lineWidth'), symbol=d.get('symbol'), symbolSize=d.get('symbolSize')) curves = Property("QStringList", getCurves, setCurves) def findCurve(self, pv_name): """ Find a curve from a graph's curve list. Parameters ---------- pv_name : str The curve's PV address. Returns ------- curve : TimePlotCurveItem The found curve, or None. """ for curve in self._curves: if curve.address == pv_name: return curve def refreshCurve(self, curve): """ Remove a curve currently being plotted on the timeplot, then redraw that curve, which could have been updated with a new symbol, line style, line width, etc. Parameters ---------- curve : TimePlotCurveItem The curve to be re-added. """ curve = self.findCurve(curve.channel) if curve: self.removeYChannel(curve) self.addYChannel(y_channel=curve.address, color=curve.color, name=curve.address, lineStyle=curve.lineStyle, lineWidth=curve.lineWidth, symbol=curve.symbol, symbolSize=curve.symbolSize) def addLegendItem(self, item, pv_name, force_show_legend=False): """ Add an item into the graph's legend. Parameters ---------- item : TimePlotCurveItem A curve being plotted in the graph pv_name : str The PV channel force_show_legend : bool True to make the legend to be displayed; False to just add the item, but do not display the legend. """ self._legend.addItem(item, pv_name) self.setShowLegend(force_show_legend) def removeLegendItem(self, pv_name): """ Remove an item from the legend. Parameters ---------- pv_name : str The PV channel, used to search for the legend item to remove. """ self._legend.removeItem(pv_name) if len(self._legend.items) == 0: self.setShowLegend(False) def getBufferSize(self): """ Get the size of the data buffer for the entire chart. Returns ------- size : int The chart's data buffer size. """ return int(self._bufferSize) def setBufferSize(self, value): """ Set the size of the data buffer of the entire chart. This will also update the same value for each of the data buffer of each chart's curve. Parameters ---------- value : int The new buffer size for the chart. """ if self._bufferSize != int(value): # Originally, the bufferSize is the max between the user's input and 1, and 1 doesn't make sense. # So, I'm comparing the user's input with the minimum buffer size, and pick the max between the two self._bufferSize = max(int(value), MINIMUM_BUFFER_SIZE) for curve in self._curves: curve.setBufferSize(value) def resetBufferSize(self): """ Reset the data buffer size of the chart, and each of the chart's curve's data buffer, to the minimum """ if self._bufferSize != DEFAULT_BUFFER_SIZE: self._bufferSize = DEFAULT_BUFFER_SIZE for curve in self._curves: curve.resetBufferSize() bufferSize = Property("int", getBufferSize, setBufferSize, resetBufferSize) def getUpdatesAsynchronously(self): return self._update_mode == PyDMTimePlot.AsynchronousMode def setUpdatesAsynchronously(self, value): for curve in self._curves: curve.setUpdatesAsynchronously(value) if value is True: self._update_mode = PyDMTimePlot.AsynchronousMode self.update_timer.start() else: self._update_mode = PyDMTimePlot.SynchronousMode self.update_timer.stop() def resetUpdatesAsynchronously(self): self._update_mode = PyDMTimePlot.SynchronousMode self.update_timer.stop() for curve in self._curves: curve.resetUpdatesAsynchronously() updatesAsynchronously = Property("bool", getUpdatesAsynchronously, setUpdatesAsynchronously, resetUpdatesAsynchronously) def getTimeSpan(self): """ The extent of the x-axis of the chart, in seconds. In other words, how long a data point stays on the plot before falling off the left edge. Returns ------- time_span : float The extent of the x-axis of the chart, in seconds. """ return float(self._time_span) def setTimeSpan(self, value): """ Set the extent of the x-axis of the chart, in seconds. In aynchronous mode, the chart will allocate enough buffer for the new time span duration. Data arriving after each duration will be recorded into the buffer having been rotated. Parameters ---------- value : float The time span duration, in seconds, to allocate enough buffer to collect data for, before rotating the buffer. """ value = float(value) if self._time_span != value: self._time_span = value if self.getUpdatesAsynchronously(): self.setBufferSize( int((self._time_span * 1000.0) / self._update_interval)) self.updateXAxis(update_immediately=True) def resetTimeSpan(self): """ Reset the timespan to the default value. """ if self._time_span != DEFAULT_TIME_SPAN: self._time_span = DEFAULT_TIME_SPAN if self.getUpdatesAsynchronously(): self.setBufferSize( int((self._time_span * 1000.0) / self._update_interval)) self.updateXAxis(update_immediately=True) timeSpan = Property(float, getTimeSpan, setTimeSpan, resetTimeSpan) def getUpdateInterval(self): """ Get the update interval for the chart. Returns ------- interval : float The update interval of the chart. """ return float(self._update_interval) / 1000.0 def setUpdateInterval(self, value): """ Set a new update interval for the chart and update its data buffer size. Parameters ---------- value : float The new update interval value. """ value = abs(int(1000.0 * value)) if self._update_interval != value: self._update_interval = value self.update_timer.setInterval(self._update_interval) if self.getUpdatesAsynchronously(): self.setBufferSize( int((self._time_span * 1000.0) / self._update_interval)) def resetUpdateInterval(self): """ Reset the chart's update interval to the default. """ if self._update_interval != DEFAULT_UPDATE_INTERVAL: self._update_interval = DEFAULT_UPDATE_INTERVAL self.update_timer.setInterval(self._update_interval) if self.getUpdatesAsynchronously(): self.setBufferSize( int((self._time_span * 1000.0) / self._update_interval)) updateInterval = Property(float, getUpdateInterval, setUpdateInterval, resetUpdateInterval) def getAutoRangeX(self): if self._plot_by_timestamps: return False else: super(PyDMTimePlot, self).getAutoRangeX() def setAutoRangeX(self, value): if self._plot_by_timestamps: self._auto_range_x = False self.plotItem.enableAutoRange(ViewBox.XAxis, enable=self._auto_range_x) else: super(PyDMTimePlot, self).setAutoRangeX(value) def channels(self): return [curve.channel for curve in self._curves] # The methods for autoRangeY, minYRange, and maxYRange are # all defined in BasePlot, but we don't expose them as properties there, because not all plot # subclasses necessarily want them to be user-configurable in Designer. autoRangeY = Property(bool, BasePlot.getAutoRangeY, BasePlot.setAutoRangeY, BasePlot.resetAutoRangeY, doc=""" Whether or not the Y-axis automatically rescales to fit the data. If true, the values in minYRange and maxYRange are ignored. """) minYRange = Property(float, BasePlot.getMinYRange, BasePlot.setMinYRange, doc=""" Minimum Y-axis value visible on the plot.""") maxYRange = Property(float, BasePlot.getMaxYRange, BasePlot.setMaxYRange, doc=""" Maximum Y-axis value visible on the plot.""") def enableCrosshair(self, is_enabled, starting_x_pos=DEFAULT_X_MIN, starting_y_pos=DEFAULT_Y_MIN, vertical_angle=90, horizontal_angle=0, vertical_movable=False, horizontal_movable=False): """ Display a crosshair on the graph. Parameters ---------- is_enabled : bool True is to display the crosshair; False is to hide it. starting_x_pos : float The x position where the vertical line will cross starting_y_pos : float The y position where the horizontal line will cross vertical_angle : int The angle of the vertical line horizontal_angle : int The angle of the horizontal line vertical_movable : bool True if the user can move the vertical line; False if not horizontal_movable : bool True if the user can move the horizontal line; False if not """ super(PyDMTimePlot, self).enableCrosshair(is_enabled, starting_x_pos, starting_y_pos, vertical_angle, horizontal_angle, vertical_movable, horizontal_movable)
class LEDButton(ActionButton): RULE_PROPERTIES = ActionButton.RULE_PROPERTIES.copy() RULE_PROPERTIES.update({ 'LED On': ['setLedState', bool], 'LED Flashing': ['setLedFlashing', bool] }) def __init__(self, parent=None): super(LEDButton, self).__init__(parent) self._alignment = Qt.AlignRight | Qt.AlignTop self.led = LEDWidget(self) self.led.setDiameter(14) self.placeLed() def placeLed(self): x = 0 y = 0 alignment = self._alignment ledDiameter = self.led.getDiameter() halfLed = ledDiameter / 2 quarterLed = ledDiameter / 4 # cheap hueristic to avoid borders if alignment & Qt.AlignLeft: x = quarterLed elif alignment & Qt.AlignRight: x = self.width() - ledDiameter - quarterLed elif alignment & Qt.AlignHCenter: x = (self.width()/2) - halfLed elif alignment & Qt.AlignJustify: x = 0 if alignment & Qt.AlignTop: y = quarterLed elif alignment & Qt.AlignBottom: y = self.height() - ledDiameter - quarterLed elif alignment & Qt.AlignVCenter: y = self.height()/2 - halfLed # print x, y self.led.move(x, y) self.updateGeometry() def resizeEvent(self, event): self.placeLed() def update(self): # self.placeLed() # super(LEDButton, self).update() pass def sizeHint( self ): return QSize(80, 30) @Slot(bool) def setLedState(self, state): self.led.setState(state) @Slot(bool) def setLedFlashing(self, flashing): self.led.setFlashing(flashing) def getLedDiameter(self): return self.led.getDiameter() @Slot(int) def setLedDiameter(self, value): self.led.setDiameter(value) self.placeLed() def getLedColor(self): return self.led.getColor() @Slot(QColor) def setLedColor(self, value): self.led.setColor(value) def getAlignment(self): return self._alignment @Slot(Qt.Alignment) def setAlignment(self, value): self._alignment = Qt.Alignment(value) self.update() diameter = Property(int, getLedDiameter, setLedDiameter) color = Property(QColor, getLedColor, setLedColor) alignment = Property(Qt.Alignment, getAlignment, setAlignment)
class LEDWidget(QWidget): def __init__(self, parent=None): super(LEDWidget, self).__init__(parent) self._diamX = 0 self._diamY = 0 self._diameter = 30 self._color = QColor("red") self._alignment = Qt.AlignCenter self._state = True self._flashing = False self._flashRate = 200 self._timer = QTimer() self._timer.timeout.connect(self.toggleState) self.setDiameter(self._diameter) def paintEvent(self, event): painter = QPainter() x = 0 y = 0 if self._alignment & Qt.AlignLeft: x = 0 elif self._alignment & Qt.AlignRight: x = self.width() - self._diameter elif self._alignment & Qt.AlignHCenter: x = (self.width() - self._diameter) / 2 elif self._alignment & Qt.AlignJustify: x = 0 if self._alignment & Qt.AlignTop: y = 0 elif self._alignment & Qt.AlignBottom: y = self.height() - self._diameter elif self._alignment & Qt.AlignVCenter: y = (self.height() - self._diameter) / 2 gradient = QRadialGradient(x + self._diameter / 2, y + self._diameter / 2, self._diameter * 0.3, self._diameter * 0.1, self._diameter * 0.1) gradient.setColorAt(0, Qt.white) # ensure the border/halo is same color as gradient draw_color = QColor(self._color) if not self._state: # cut to black @ 70% for darker effect draw_color = QColor(Qt.black) if not self.isEnabled(): draw_color.setAlpha(30) pen_color = draw_color gradient.setColorAt(0.7, draw_color) painter.begin(self) brush = QBrush(gradient) painter.setPen(pen_color) painter.setRenderHint(QPainter.Antialiasing, True) painter.setBrush(brush) painter.drawEllipse(x + 1, y + 1, self._diameter - 2, self._diameter - 2) if self._flashRate > 0 and self._flashing: self._timer.start(self._flashRate) else: self._timer.stop() painter.end() def minimumSizeHint(self): return QSize(self._diameter, self._diameter) def sizeHint(self): return QSize(self._diameter, self._diameter) def getDiameter(self): return self._diameter @Slot(int) def setDiameter(self, value): self._diameter = value self.adjustSize() self.update() def getColor(self): return self._color @Slot(QColor) def setColor(self, value): self._color = value self._disabledColor = QColor(self._color) self._disabledColor.setAlpha(30) self.update() def getAlignment(self): return self._alignment @Slot(Qt.Alignment) def setAlignment(self, value): self._alignment = value self.update() def getState(self): return self._state @Slot(bool) def setState(self, value): self._state = value self.update() @Slot() def toggleState(self): self._state = not self._state self.update() def isFlashing(self): return self._flashing @Slot(bool) def setFlashing(self, value): self._flashing = value self.update() def getFlashRate(self): return self._flashRate @Slot(int) def setFlashRate(self, value): self._flashRate = value self.update() @Slot() def startFlashing(self): self.setFlashing(True) @Slot() def stopFlashing(self): self.setFlashing(False) diameter = Property(int, getDiameter, setDiameter) color = Property(QColor, getColor, setColor) alignment = Property(Qt.Alignment, getAlignment, setAlignment) state = Property(bool, getState, setState) flashing = Property(bool, isFlashing, setFlashing) flashRate = Property(int, getFlashRate, setFlashRate)
class GcodeBackplot(QBackPlot): line_selected = Signal(int) gcode_error = Signal(str) def __init__(self, parent=None, standalone=False): super(GcodeBackplot, self).__init__(parent) # This prevents doing unneeded initialization # when QtDesginer loads the plugin. if parent is None and not standalone: return self.show_overlay = False # no DRO or DRO overlay self.program_alpha = True self.grid_size = 1 self._reload_filename = None # Add loading progress bar and abort button self.progressBar = QProgressBar(visible=False) self.progressBar.setFormat("Loading backplot: %p%") self.abortButton = QPushButton('Abort', visible=False) hBox = QHBoxLayout() hBox.addWidget(self.progressBar) hBox.addWidget(self.abortButton) vBox = QVBoxLayout(self) vBox.addStretch() vBox.addLayout(hBox) self.abortButton.clicked.connect(self.abort) STATUS.actual_position.onValueChanged(self.update) STATUS.joint_actual_position.onValueChanged(self.update) STATUS.homed.onValueChanged(self.update) STATUS.limit.onValueChanged(self.update) STATUS.tool_in_spindle.onValueChanged(self.update) STATUS.motion_mode.onValueChanged(self.update) STATUS.current_vel.onValueChanged(self.update) STATUS.g5x_offset.onValueChanged(self.reloadBackplot) STATUS.g92_offset.onValueChanged(self.reloadBackplot) # Connect status signals STATUS.file.notify(self.loadBackplot) # STATUS.reload_backplot.notify(self.reloadBackplot) STATUS.program_units.notify(lambda v: self.setMetricUnits(v == 2)) def loadBackplot(self, fname): LOG.debug('load the display: {}'.format(fname.encode('utf-8'))) self._reload_filename = fname self.load(fname) @Slot() def reloadBackplot(self): QTimer.singleShot(100, lambda: self._reloadBackplot()) def _reloadBackplot(self): LOG.debug('reload the display: {}'.format(self._reload_filename)) dist = self.get_zoom_distance() try: self.load(self._reload_filename) self.set_zoom_distance(dist) except: LOG.warning("Problem reloading backplot file: {}".format(self._reload_filename), exc_info=True) # ========================================================================== # Override QBackPlot methods # ========================================================================== def report_loading_started(self): self.progressBar.show() self.abortButton.show() self.start = time.time() def report_progress_percentage(self, percentage): QApplication.processEvents() self.progressBar.setValue(percentage) def report_loading_finished(self): print((time.time() - self.start)) self.progressBar.hide() self.abortButton.hide() # overriding functions def report_gcode_error(self, result, seq, filename): error = gcode.strerror(result) file = os.path.basename(filename) line = seq - 1 msg = "G-code error in '{}' near line {}: {}".format(file, line, error) LOG.error(msg) STATUS.backplot_gcode_error.emit(msg) # Override gremlin's / glcannon.py function so we can emit a GObject signal def update_highlight_variable(self, line): self.highlight_line = line if line is None: line = -1 STATUS.backplot_line_selected.emit(line) # ============================================================================== # QtDesigner property setters/getters # ============================================================================== @Slot(str) def setView(self, view): view = view.lower() if self.is_lathe: if view not in ['p', 'y', 'y2']: return False elif view not in ['p', 'x', 'y', 'z', 'z2']: return False self.current_view = view if self.initialised: self.set_current_view() def getView(self): return self.current_view defaultView = Property(str, getView, setView) @Slot() def setViewP(self): self.setView('p') @Slot() def setViewX(self): self.setView('x') @Slot() def setViewY(self): self.setView('y') @Slot() def setViewZ(self): self.setView('z') @Slot() def setViewZ2(self): self.setView('z2') @Slot() def clearLivePlot(self): self.clear_live_plotter() @Slot() def zoomIn(self): self.zoomin() @Slot() def zoomOut(self): self.zoomout() @Slot(bool) def alphaBlend(self, alpha): self.program_alpha = alpha self.update() @Slot(bool) def showGrid(self, grid): self.grid_size = int(grid) # ugly hack for now self.update() # @Slot(str) Fixme check for the correct data type def setdro(self, state): self.enable_dro = state self.updateGL() def getdro(self): return self.enable_dro _dro = Property(bool, getdro, setdro) # DTG # @Slot(str) Fixme check for the correct data type def setdtg(self, state): self.show_dtg = state self.updateGL() def getdtg(self): return self.show_dtg _dtg = Property(bool, getdtg, setdtg) # METRIC # @Slot(str) Fixme check for the correct data type def setMetricUnits(self, metric): self.metric_units = metric self.updateGL() def getMetricUnits(self): return self.metric_units metricUnits = Property(bool, getMetricUnits, setMetricUnits) # @Slot(str) Fixme check for the correct data type def setProgramAlpha(self, alpha): self.program_alpha = alpha self.updateGL() def getProgramAlpha(self): return self.program_alpha renderProgramAlpha = Property(bool, getProgramAlpha, setProgramAlpha) # @Slot(str) Fixme check for the correct data type def setBackgroundColor(self, color): self.colors['back'] = color.getRgbF()[:3] self.updateGL() def getBackgroundColor(self): r, g, b = self.colors['back'] color = QColor() color.setRgbF(r, g, b, 1.0) return color backgroundColor = Property(QColor, getBackgroundColor, setBackgroundColor)
class JogIncrementWidget(QWidget): def __init__(self, parent=None, standalone=False): super(JogIncrementWidget, self).__init__(parent) self._container = hBox = QBoxLayout(QBoxLayout.LeftToRight, self) hBox.setContentsMargins(0, 0, 0, 0) self._ledDiameter = 15 self._ledColor = QColor('green') self._alignment = Qt.AlignTop | Qt.AlignRight # This prevents doing unneeded initialization # when QtDesginer loads the plugin. if parent is None and not standalone: return increments = INFO.getIncrements() for increment in increments: button = LEDButton() button.setCheckable(True) button.setAutoExclusive(True) button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) button.setMinimumSize(50, 42) if increment != 0: raw_increment = increment.strip() # print '[', raw_increment, ']' button.setText(raw_increment) button.clicked.connect(self.setJogIncrement) hBox.addWidget(button) self.placeLed() def setJogIncrement(self): setSetting('machine.jog.increment', self.sender().text()) def layoutWidgets(self, layout): return (layout.itemAt(i) for i in range(layout.count())) def placeLed(self): for w in self.layoutWidgets(self._container): w.widget().setLedDiameter(self._ledDiameter) w.widget().setLedColor(self._ledColor) w.widget().setAlignment(self._alignment) def getLedDiameter(self): return self._ledDiameter @Slot(int) def setLedDiameter(self, value): self._ledDiameter = value self.placeLed() def getLedColor(self): return self._ledColor @Slot(QColor) def setLedColor(self, value): self._ledColor = value self.placeLed() def getAlignment(self): return self._alignment @Slot(Qt.Alignment) def setAlignment(self, value): self._alignment = Qt.Alignment(value) self.placeLed() def getOrientation(self): if self._container.direction() == QBoxLayout.LeftToRight: return Qt.Horizontal else: return Qt.Vertical @Slot(Qt.Orientation) def setOrientation(self, value): if value == Qt.Horizontal: self._container.setDirection(QBoxLayout.LeftToRight) else: self._container.setDirection(QBoxLayout.TopToBottom) self.adjustSize() def getLayoutSpacing(self): return self._container.spacing() @Slot(int) def setLayoutSpacing(self, value): self._container.setSpacing(value) diameter = Property(int, getLedDiameter, setLedDiameter) color = Property(QColor, getLedColor, setLedColor) alignment = Property(Qt.Alignment, getAlignment, setAlignment) orientation = Property(Qt.Orientation, getOrientation, setOrientation) layoutSpacing = Property(int, getLayoutSpacing, setLayoutSpacing)
class BasePlot(PlotWidget, PyDMPrimitiveWidget): crosshair_position_updated = Signal(float, float) def __init__(self, parent=None, background='default', axisItems=None): PlotWidget.__init__(self, parent=parent, background=background, axisItems=axisItems) PyDMPrimitiveWidget.__init__(self) self.plotItem = self.getPlotItem() self.plotItem.hideButtons() self._auto_range_x = None self.setAutoRangeX(True) self._auto_range_y = None self.setAutoRangeY(True) self._min_x = 0.0 self._max_x = 1.0 self._min_y = 0.0 self._max_y = 1.0 self._show_x_grid = None self.setShowXGrid(False) self._show_y_grid = None self.setShowYGrid(False) self._show_right_axis = False self.redraw_timer = QTimer(self) self.redraw_timer.timeout.connect(self.redrawPlot) self._redraw_rate = 30 # Redraw at 30 Hz by default. self.maxRedrawRate = self._redraw_rate self._curves = [] self._title = None self._show_legend = False self._legend = self.addLegend() self._legend.hide() # Drawing crosshair on the ViewBox self.vertical_crosshair_line = None self.horizontal_crosshair_line = None self.crosshair_movement_proxy = None def addCurve(self, plot_item, curve_color=None): if curve_color is None: curve_color = utilities.colors.default_colors[ len(self._curves) % len(utilities.colors.default_colors)] plot_item.color_string = curve_color self._curves.append(plot_item) self.addItem(plot_item) self.redraw_timer.start() # Connect channels for chan in plot_item.channels(): if chan: chan.connect() # self._legend.addItem(plot_item, plot_item.curve_name) def removeCurve(self, plot_item): self.removeItem(plot_item) self._curves.remove(plot_item) if len(self._curves) < 1: self.redraw_timer.stop() # Disconnect channels for chan in plot_item.channels(): if chan: chan.disconnect() def removeCurveWithName(self, name): for curve in self._curves: if curve.name() == name: self.removeCurve(curve) def removeCurveAtIndex(self, index): curve_to_remove = self._curves[index] self.removeCurve(curve_to_remove) def setCurveAtIndex(self, index, new_curve): old_curve = self._curves[index] self._curves[index] = new_curve # self._legend.addItem(new_curve, new_curve.name()) self.removeCurve(old_curve) def curveAtIndex(self, index): return self._curves[index] def curves(self): return self._curves def clear(self): legend_items = [label.text for (sample, label) in self._legend.items] for item in legend_items: self._legend.removeItem(item) self.plotItem.clear() self._curves = [] @Slot() def redrawPlot(self): pass def getShowXGrid(self): return self._show_x_grid def setShowXGrid(self, value, alpha=None): self._show_x_grid = value self.showGrid(x=self._show_x_grid, alpha=alpha) def resetShowXGrid(self): self.setShowXGrid(False) showXGrid = Property("bool", getShowXGrid, setShowXGrid, resetShowXGrid) def getShowYGrid(self): return self._show_y_grid def setShowYGrid(self, value, alpha=None): self._show_y_grid = value self.showGrid(y=self._show_y_grid, alpha=alpha) def resetShowYGrid(self): self.setShowYGrid(False) showYGrid = Property("bool", getShowYGrid, setShowYGrid, resetShowYGrid) def getBackgroundColor(self): return self.backgroundBrush().color() def setBackgroundColor(self, color): if self.backgroundBrush().color() != color: self.setBackgroundBrush(QBrush(color)) backgroundColor = Property(QColor, getBackgroundColor, setBackgroundColor) def getAxisColor(self): return self.getAxis('bottom')._pen.color() def setAxisColor(self, color): if self.getAxis('bottom')._pen.color() != color: self.getAxis('bottom').setPen(color) self.getAxis('left').setPen(color) self.getAxis('top').setPen(color) self.getAxis('right').setPen(color) axisColor = Property(QColor, getAxisColor, setAxisColor) def getBottomAxisLabel(self): return self.getAxis('bottom').labelText def getShowRightAxis(self): """ Provide whether the right y-axis is being shown. Returns : bool ------- True if the graph shows the right y-axis. False if not. """ return self._show_right_axis def setShowRightAxis(self, show): """ Set whether the graph should show the right y-axis. Parameters ---------- show : bool True for showing the right axis; False is for not showing. """ if show: self.showAxis("right") else: self.hideAxis("right") self._show_right_axis = show showRightAxis = Property("bool", getShowRightAxis, setShowRightAxis) def getPlotTitle(self): if self._title is None: return "" return str(self._title) def setPlotTitle(self, value): self._title = str(value) if len(self._title) < 1: self._title = None self.setTitle(self._title) def resetPlotTitle(self): self._title = None self.setTitle(self._title) title = Property(str, getPlotTitle, setPlotTitle, resetPlotTitle) def getShowLegend(self): """ Check if the legend is being shown. Returns : bool ------- True if the legend is displayed on the graph; False if not. """ return self._show_legend def setShowLegend(self, value): """ Set to display the legend on the graph. Parameters ---------- value : bool True to display the legend; False is not. """ self._show_legend = value if self._show_legend: if self._legend is None: self._legend = self.addLegend() else: self._legend.show() else: if self._legend is not None: self._legend.hide() def resetShowLegend(self): """ Reset the legend display status to hidden. """ self.setShowLegend(False) showLegend = Property(bool, getShowLegend, setShowLegend, resetShowLegend) def getAutoRangeX(self): return self._auto_range_x def setAutoRangeX(self, value): self._auto_range_x = value if self._auto_range_x: self.plotItem.enableAutoRange(ViewBox.XAxis, enable=self._auto_range_x) def resetAutoRangeX(self): self.setAutoRangeX(True) def getAutoRangeY(self): return self._auto_range_y def setAutoRangeY(self, value): self._auto_range_y = value if self._auto_range_y: self.plotItem.enableAutoRange(ViewBox.YAxis, enable=self._auto_range_y) def resetAutoRangeY(self): self.setAutoRangeY(True) def getMinXRange(self): """ Minimum X-axis value visible on the plot. Returns ------- float """ return self.plotItem.viewRange()[0][0] def setMinXRange(self, new_min_x_range): """ Set the minimum X-axis value visible on the plot. Parameters ------- new_min_x_range : float """ if self._auto_range_x: return self._min_x = new_min_x_range self.plotItem.setXRange(self._min_x, self._max_x, padding=0) def getMaxXRange(self): """ Maximum X-axis value visible on the plot. Returns ------- float """ return self.plotItem.viewRange()[0][1] def setMaxXRange(self, new_max_x_range): """ Set the Maximum X-axis value visible on the plot. Parameters ------- new_max_x_range : float """ if self._auto_range_x: return self._max_x = new_max_x_range self.plotItem.setXRange(self._min_x, self._max_x, padding=0) def getMinYRange(self): """ Minimum Y-axis value visible on the plot. Returns ------- float """ return self.plotItem.viewRange()[1][0] def setMinYRange(self, new_min_y_range): """ Set the minimum Y-axis value visible on the plot. Parameters ------- new_min_y_range : float """ if self._auto_range_y: return self._min_y = new_min_y_range self.plotItem.setYRange(self._min_y, self._max_y, padding=0) def getMaxYRange(self): """ Maximum Y-axis value visible on the plot. Returns ------- float """ return self.plotItem.viewRange()[1][1] def setMaxYRange(self, new_max_y_range): """ Set the maximum Y-axis value visible on the plot. Parameters ------- new_max_y_range : float """ if self._auto_range_y: return self._max_y = new_max_y_range self.plotItem.setYRange(self._min_y, self._max_y, padding=0) @Property(bool) def mouseEnabledX(self): """ Whether or not mouse interactions are enabled for the X-axis. Returns ------- bool """ return self.plotItem.getViewBox().state['mouseEnabled'][0] @mouseEnabledX.setter def mouseEnabledX(self, x_enabled): """ Whether or not mouse interactions are enabled for the X-axis. Parameters ------- x_enabled : bool """ self.plotItem.setMouseEnabled(x=x_enabled) @Property(bool) def mouseEnabledY(self): """ Whether or not mouse interactions are enabled for the Y-axis. Returns ------- bool """ return self.plotItem.getViewBox().state['mouseEnabled'][1] @mouseEnabledY.setter def mouseEnabledY(self, y_enabled): """ Whether or not mouse interactions are enabled for the Y-axis. Parameters ------- y_enabled : bool """ self.plotItem.setMouseEnabled(y=y_enabled) @Property(int) def maxRedrawRate(self): """ The maximum rate (in Hz) at which the plot will be redrawn. The plot will not be redrawn if there is not new data to draw. Returns ------- int """ return self._redraw_rate @maxRedrawRate.setter def maxRedrawRate(self, redraw_rate): """ The maximum rate (in Hz) at which the plot will be redrawn. The plot will not be redrawn if there is not new data to draw. Parameters ------- redraw_rate : int """ self._redraw_rate = redraw_rate self.redraw_timer.setInterval(int((1.0/self._redraw_rate)*1000)) def pausePlotting(self): self.redraw_timer.stop() if self.redraw_timer.isActive() else self.redraw_timer.start() return self.redraw_timer.isActive() def mouseMoved(self, evt): """ A handler for the crosshair feature. Every time the mouse move, the mouse coordinates are updated, and the horizontal and vertical hairlines will be redrawn at the new coordinate. If a PyDMDisplay object is available, that display will also have the x- and y- values to update on the UI. Parameters ------- evt: MouseEvent The mouse event type, from which the mouse coordinates are obtained. """ pos = evt[0] if self.sceneBoundingRect().contains(pos): mouse_point = self.getViewBox().mapSceneToView(pos) self.vertical_crosshair_line.setPos(mouse_point.x()) self.horizontal_crosshair_line.setPos(mouse_point.y()) self.crosshair_position_updated.emit(mouse_point.x(), mouse_point.y()) def enableCrosshair(self, is_enabled, starting_x_pos, starting_y_pos, vertical_angle=90, horizontal_angle=0, vertical_movable=False, horizontal_movable=False): """ Enable the crosshair to be drawn on the ViewBox. Parameters ---------- is_enabled : bool True is to draw the crosshair, False is to not draw. starting_x_pos : float The x coordinate where to start the vertical crosshair line. starting_y_pos : float The y coordinate where to start the horizontal crosshair line. vertical_angle : float The angle to tilt the vertical crosshair line. Default at 90 degrees. horizontal_angle The angle to tilt the horizontal crosshair line. Default at 0 degrees. vertical_movable : bool True if the vertical line can be moved by the user; False is not. horizontal_movable False if the horizontal line can be moved by the user; False is not. """ if is_enabled: self.vertical_crosshair_line = InfiniteLine(pos=starting_x_pos, angle=vertical_angle, movable=vertical_movable) self.horizontal_crosshair_line = InfiniteLine(pos=starting_y_pos, angle=horizontal_angle, movable=horizontal_movable) self.plotItem.addItem(self.vertical_crosshair_line) self.plotItem.addItem(self.horizontal_crosshair_line) self.crosshair_movement_proxy = SignalProxy(self.plotItem.scene().sigMouseMoved, rateLimit=60, slot=self.mouseMoved) else: if self.vertical_crosshair_line: self.plotItem.removeItem(self.vertical_crosshair_line) if self.horizontal_crosshair_line: self.plotItem.removeItem(self.horizontal_crosshair_line) if self.crosshair_movement_proxy: self.crosshair_movement_proxy.disconnect()
class GcodeEditor(EditorBase, QObject): ARROW_MARKER_NUM = 8 def __init__(self, parent=None): super(GcodeEditor, self).__init__(parent) self._last_filename = None self.auto_show_mdi = True self.last_line = None # self.setEolVisibility(True) STATUS.file.notify(self.load_program) STATUS.motion_line.onValueChanged(self.highlight_line) # STATUS.connect('line-changed', self.highlight_line) # if self.idle_line_reset: # STATUS.connect('interp_idle', lambda w: self.set_line_number(None, 0)) # QSS Hack self._backgroundcolor = '' self.backgroundcolor = self._backgroundcolor self._marginbackgroundcolor = '' self.marginbackgroundcolor = self._marginbackgroundcolor @Property(str) def backgroundcolor(self): """Property to set the background color of the GCodeEditor (str). sets the background color of the GCodeEditor """ return self._backgroundcolor @backgroundcolor.setter def backgroundcolor(self, color): self._backgroundcolor = color self.set_background_color(color) @Property(str) def marginbackgroundcolor(self): """Property to set the background color of the GCodeEditor margin (str). sets the background color of the GCodeEditor margin """ return self._marginbackgroundcolor @marginbackgroundcolor.setter def marginbackgroundcolor(self, color): self._marginbackgroundcolor = color self.set_margin_background_color(color) def load_program(self, fname=None): if fname is None: fname = self._last_filename else: self._last_filename = fname self.load_text(fname) # self.zoomTo(6) self.setCursorPosition(0, 0) # when switching from MDI to AUTO we need to reload the # last (linuxcnc loaded) program. def reload_last(self): self.load_text(STATUS.old['file']) self.setCursorPosition(0, 0) # With the auto_show__mdi option, MDI history is shown def load_mdi(self): self.load_text(INFO.MDI_HISTORY_PATH) self._last_filename = INFO.MDI_HISTORY_PATH # print 'font point size', self.font().pointSize() # self.zoomTo(10) # print 'font point size', self.font().pointSize() self.setCursorPosition(self.lines(), 0) # With the auto_show__mdi option, MDI history is shown def load_manual(self): if STATUS.is_man_mode(): self.load_text(INFO.MACHINE_LOG_HISTORY_PATH) self.setCursorPosition(self.lines(), 0) def load_text(self, fname): try: fp = os.path.expanduser(fname) self.setText(open(fp).read()) except: LOG.error('File path is not valid: {}'.format(fname)) self.setText('') return self.last_line = None self.ensureCursorVisible() self.SendScintilla(QsciScintilla.SCI_VERTICALCENTRECARET) def highlight_line(self, line): # if STATUS.is_auto_running(): # if not STATUS.old['file'] == self._last_filename: # LOG.debug('should reload the display') # self.load_text(STATUS.old['file']) # self._last_filename = STATUS.old['file'] self.markerAdd(line, self.ARROW_MARKER_NUM) if self.last_line: self.markerDelete(self.last_line, self.ARROW_MARKER_NUM) self.setCursorPosition(line, 0) self.ensureCursorVisible() self.SendScintilla(QsciScintilla.SCI_VERTICALCENTRECARET) self.last_line = line def set_line_number(self, line): pass def line_changed(self, line, index): # LOG.debug('Line changed: {}'.format(STATUS.is_auto_mode())) self.line_text = str(self.text(line)).strip() self.line = line if STATUS.is_mdi_mode() and STATUS.is_auto_running() is False: STATUS.emit('mdi-line-selected', self.line_text, self._last_filename) def select_lineup(self): line, col = self.getCursorPosition() LOG.debug(line) self.setCursorPosition(line - 1, 0) self.highlight_line(line - 1) def select_linedown(self): line, col = self.getCursorPosition() LOG.debug(line) self.setCursorPosition(line + 1, 0) self.highlight_line(line + 1) # designer recognized getter/setters # auto_show_mdi status def set_auto_show_mdi(self, data): self.auto_show_mdi = data def get_auto_show_mdi(self): return self.auto_show_mdi def reset_auto_show_mdi(self): self.auto_show_mdi = True auto_show_mdi_status = Property(bool, get_auto_show_mdi, set_auto_show_mdi, reset_auto_show_mdi)
class HALLEDButton(QPushButton): """HAL LED Button""" def __init__(self, parent=None): super(HALLEDButton, self).__init__(parent) self._alignment = Qt.AlignRight | Qt.AlignTop self._pin_name = '' self._flash_pin_name = '' # self.setCheckable(True) self.led = LEDWidget(self) self.led.setDiameter(14) # self.toggled.connect(self.updateState) # self.updateState() self.placeLed() def placeLed(self): x = 0 y = 0 alignment = self._alignment ledDiameter = self.led.getDiameter() halfLed = ledDiameter / 2 quarterLed = ledDiameter /4 # cheap hueristic to avoid borders if alignment & Qt.AlignLeft: x = quarterLed elif alignment & Qt.AlignRight: x = self.width() - ledDiameter - quarterLed elif alignment & Qt.AlignHCenter: x = (self.width()/2) - halfLed elif alignment & Qt.AlignJustify: x = 0 if alignment & Qt.AlignTop: y = quarterLed elif alignment & Qt.AlignBottom: y = self.height() - ledDiameter - quarterLed elif alignment & Qt.AlignVCenter: y = self.height()/2 - halfLed # print(x, y) self.led.move(x, y) self.updateGeometry() def resizeEvent(self, event): self.placeLed() def update(self): self.placeLed() super(LEDButton, self).update() def updateState(self, state): self.led.setState(state) def updateFlashing(self, flashing): self.led.setFlashing(flashing) def sizeHint( self ): return QSize(80, 30) def getLedDiameter(self): return self.led.getDiameter() @Slot(int) def setLedDiameter(self, value): self.led.setDiameter(value) self.placeLed() def getLedColor(self): return self.led.getColor() @Slot(QColor) def setLedColor(self, value): self.led.setColor(value) def getAlignment(self): return self._alignment @Slot(Qt.Alignment) def setAlignment(self, value): self._alignment = Qt.Alignment(value) self.update() diameter = Property(int, getLedDiameter, setLedDiameter) color = Property(QColor, getLedColor, setLedColor) alignment = Property(Qt.Alignment, getAlignment, setAlignment) @Property(str) def flashPinName(self): """The `actionName` property for setting the action the button should trigger from within QtDesigner. Returns: str : The action name. """ return self._flash_pin_name @flashPinName.setter def flashPinName(self, flash_pin_name): """Sets the name of the action the button should trigger and binds the widget to that action. Args: action_name (str) : A fully qualified action name. """ self._flash_pin_name = flash_pin_name try: hal_pin = hal_status.getHALPin(flash_pin_name) except ValueError: return hal_pin.connect(self.updateState) @Property(str) def pinName(self): """The `actionName` property for setting the action the button should trigger from within QtDesigner. Returns: str : The action name. """ return self._pin_name @pinName.setter def pinName(self, pin_name): """Sets the name of the action the button should trigger and binds the widget to that action. Args: action_name (str) : A fully qualified action name. """ self._pin_name = pin_name try: hal_pin = hal_status.getHALPin(pin_name) except ValueError: return hal_pin.connect(self.updateState)
class QRangeSlider(QWidget): """ QRangeSlider class, super class for QVRangeSlider and QHRangeSlider. """ valuesChanged = Signal(tuple) rangeChanged = Signal(tuple) collapsedChanged = Signal(bool) focused = Signal() resized = Signal() def __init__( self, initial_values=None, data_range=None, step_size=None, collapsible=True, collapsed=False, parent=None, ): """A range slider with two handles for min/max values. Values should be provided in the range of the underlying data. (normalization to 0-1 happens internally in the slider.sliderValues()) Parameters ---------- initial_values : 2-tuple, optional Initial min & max values of the slider, defaults to (0.2, 0.8) data_range : 2-tuple, optional Min and max of the slider range, defaults to (0, 1) step_size : float, optional Single step size for the slider, defaults to 1 collapsible : bool Whether the slider is collapsible, defaults to True. collapsed : bool Whether the slider begins collapsed, defaults to False. parent : qtpy.QtWidgets.QWidget Parent widget. """ super().__init__(parent) self.handle_radius = 8 self.slider_width = 8 self.moving = "none" self.collapsible = collapsible self.collapsed = collapsed self.prev_moving = None self.bc_min = None self.bc_max = None # Variables initialized in methods self.value_min = 0 self.value_max = 1 self.start_display_min = None self.start_display_max = None self.start_pos = None self.display_min = None self.display_max = None self.setBarColor(QColor(200, 200, 200)) self.setBackgroundColor(QColor(100, 100, 100)) self.setHandleColor(QColor(200, 200, 200)) self.setHandleBorderColor(QColor(200, 200, 200)) self.setRange((0, 100) if data_range is None else data_range) self.setValues((20, 80) if initial_values is None else initial_values) if step_size is None: # pick an appropriate slider step size based on the data range if data_range is not None: step_size = (data_range[1] - data_range[0]) / 1000 else: step_size = 0.001 self.setStep(step_size) if not parent: if 'HRange' in self.__class__.__name__: self.setGeometry(200, 200, 200, 20) else: self.setGeometry(200, 200, 20, 200) def range(self): """Min and max possible values for the slider range. In data units""" return self.data_range_min, self.data_range_max def setRange(self, values): """Min and max possible values for the slider range. In data units.""" validate_2_tuple(values) self.data_range_min, self.data_range_max = values self.rangeChanged.emit(self.range()) self.updateDisplayPositions() def values(self): """Current slider values. Returns ------- tuple Current minimum and maximum values of the range slider """ return tuple( [self._slider_to_data_value(v) for v in self.sliderValues()]) def setValues(self, values): self.setSliderValues([self._data_to_slider_value(v) for v in values]) def sliderValues(self): """Current slider values, as a fraction of slider width. Returns ------- values : 2-tuple of int Start and end of the range. """ return self.value_min, self.value_max def setSliderValues(self, values): """Set current slider values, as a fraction of slider width. Parameters ---------- values : 2-tuple of float or int Start and end of the range. """ validate_2_tuple(values) self.value_min, self.value_max = values self.valuesChanged.emit(self.values()) self.updateDisplayPositions() def setStep(self, step): self._step = step @property def single_step(self): return self._step / self.scale def mouseMoveEvent(self, event): if not self.isEnabled(): return size = self.rangeSliderSize() pos = self.getPos(event) if self.moving == "min": if pos <= self.handle_radius: self.display_min = self.handle_radius elif pos > self.display_max - self.handle_radius / 2: self.display_min = self.display_max - self.handle_radius / 2 else: self.display_min = pos elif self.moving == "max": if pos >= size + self.handle_radius: self.display_max = size + self.handle_radius elif pos < self.display_min + self.handle_radius / 2: self.display_max = self.display_min + self.handle_radius / 2 else: self.display_max = pos elif self.moving == "bar": width = self.start_display_max - self.start_display_min lower_part = self.start_pos - self.start_display_min upper_part = self.start_display_max - self.start_pos if pos + upper_part >= size + self.handle_radius: self.display_max = size + self.handle_radius self.display_min = self.display_max - width elif pos - lower_part <= self.handle_radius: self.display_min = self.handle_radius self.display_max = self.display_min + width else: self.display_min = pos - lower_part self.display_max = self.display_min + width self.updateValuesFromDisplay() def mousePressEvent(self, event): if not self.isEnabled(): return pos = self.getPos(event) top = self.rangeSliderSize() + self.handle_radius if event.button() == Qt.LeftButton: if not self.collapsed: if abs(self.display_min - pos) <= (self.handle_radius): self.moving = "min" elif abs(self.display_max - pos) <= (self.handle_radius): self.moving = "max" elif pos > self.display_min and pos < self.display_max: self.moving = "bar" elif pos > self.display_max and pos < top: self.display_max = pos self.moving = "max" self.updateValuesFromDisplay() elif pos < self.display_min and pos > self.handle_radius: self.display_min = pos self.moving = "min" self.updateValuesFromDisplay() else: self.moving = "bar" if pos > self.handle_radius and pos < top: self.display_max = pos self.display_min = pos else: if self.collapsible: if self.collapsed: self.expand() else: self.collapse() self.collapsedChanged.emit(self.collapsed) self.start_display_min = self.display_min self.start_display_max = self.display_max self.start_pos = pos self.focused.emit() def mouseReleaseEvent(self, event): if self.isEnabled(): if not (self.moving == "none"): self.valuesChanged.emit(self.values()) self.moving = "none" def collapse(self): self.bc_min, self.bc_max = self.value_min, self.value_max midpoint = (self.value_max + self.value_min) / 2 min_value = midpoint max_value = midpoint self.setSliderValues((min_value, max_value)) self.collapsed = True def expand(self): _mid = (self.bc_max - self.bc_min) / 2 min_value = self.value_min - _mid max_value = self.value_min + _mid if min_value < 0: min_value = 0 max_value = self.bc_max - self.bc_min elif max_value > 1: max_value = 1 min_value = max_value - (self.bc_max - self.bc_min) self.setSliderValues((min_value, max_value)) self.collapsed = False def resizeEvent(self, event): self.updateDisplayPositions() self.resized.emit() def updateDisplayPositions(self): size = self.rangeSliderSize() range_min = int(size * self.value_min) range_max = int(size * self.value_max) self.display_min = range_min + self.handle_radius self.display_max = range_max + self.handle_radius self.update() def _data_to_slider_value(self, value): rmin, rmax = self.range() return (value - rmin) / self.scale def _slider_to_data_value(self, value): rmin, rmax = self.range() return rmin + value * self.scale @property def scale(self): return self.data_range_max - self.data_range_min def updateValuesFromDisplay(self): size = self.rangeSliderSize() val_min, val_max = self.sliderValues() if (self.moving == "min") or (self.moving == "bar"): scale_min = (self.display_min - self.handle_radius) / size ratio = round(scale_min / self.single_step) val_min = ratio * self.single_step if (self.moving == "max") or (self.moving == "bar"): scale_max = (self.display_max - self.handle_radius) / size ratio = round(scale_max / self.single_step) val_max = ratio * self.single_step self.setSliderValues((val_min, val_max)) def getBarColor(self): return self.bar_color def setBarColor(self, barColor): self.bar_color = barColor barColor = Property(QColor, getBarColor, setBarColor) def getBackgroundColor(self): return self.background_color def setBackgroundColor(self, backgroundColor): self.background_color = backgroundColor backgroundColor = Property(QColor, getBackgroundColor, setBackgroundColor) @property def handle_width(self): return self.handle_radius * 2 def getHandleColor(self): return self.handle_color def setHandleColor(self, handleColor): self.handle_color = handleColor handleColor = Property(QColor, getHandleColor, setHandleColor) def getHandleBorderColor(self): return self.handle_border_color def setHandleBorderColor(self, handleBorderColor): self.handle_border_color = handleBorderColor handleBorderColor = Property(QColor, getHandleBorderColor, setHandleBorderColor) def setEnabled(self, bool): super().setEnabled(bool) self.update()
class PyDMWaveformPlot(BasePlot): """ PyDMWaveformPlot is a widget to plot one or more waveforms. Each curve can plot either a Y-axis waveform vs. its indices, or a Y-axis waveform against an X-axis waveform. Parameters ---------- parent : optional The parent of this widget. init_x_channels: optional init_x_channels can be a string with the address for a channel, or a list of strings, each containing an address for a channel. If not specified, y-axis waveforms will be plotted against their indices. If a list is specified for both init_x_channels and init_y_channels, they both must have the same length. If a single x channel was specified, and a list of y channels are specified, all y channels will be plotted against the same x channel. init_y_channels: optional init_y_channels can be a string with the address for a channel, or a list of strings, each containing an address for a channel. If a list is specified for both init_x_channels and init_y_channels, they both must have the same length. If a single x channel was specified, and a list of y channels are specified, all y channels will be plotted against the same x channel. background: optional The background color for the plot. Accepts any arguments that pyqtgraph.mkColor will accept. """ def __init__(self, parent=None, init_x_channels=[], init_y_channels=[], background='default'): super(PyDMWaveformPlot, self).__init__(parent, background) # If the user supplies a single string instead of a list, # wrap it in a list. if isinstance(init_x_channels, str): init_x_channels = [init_x_channels] if isinstance(init_y_channels, str): init_y_channels = [init_y_channels] if len(init_x_channels) == 0: init_x_channels = list(itertools.repeat(None, len(init_y_channels))) if len(init_x_channels) != len(init_y_channels): raise ValueError("If lists are provided for both X and Y " + "channels, they must be the same length.") # self.channel_pairs is an ordered dictionary that is keyed on a # (x_channel, y_channel) tuple, with WaveformCurveItem values. # It gets populated in self.addChannel(). self.channel_pairs = OrderedDict() init_channel_pairs = zip(init_x_channels, init_y_channels) for (x_chan, y_chan) in init_channel_pairs: self.addChannel(y_chan, x_channel=x_chan) def initialize_for_designer(self): # If we are in Qt Designer, don't update the plot continuously. # This function gets called by PyDMTimePlot's designer plugin. pass def addChannel(self, y_channel=None, x_channel=None, name=None, color=None, lineStyle=None, lineWidth=None, symbol=None, symbolSize=None, redraw_mode=None): """ Add a new curve to the plot. In addition to the arguments below, all other keyword arguments are passed to the underlying pyqtgraph.PlotDataItem used to draw the curve. Parameters ---------- y_channel: str The address for the y channel for the curve. x_channel: str, optional The address for the x channel for the curve. name: str, optional A name for this curve. The name will be used in the plot legend. color: str or QColor, optional A color for the line of the curve. If not specified, the plot will automatically assign a unique color from a set of default colors. lineStyle: int, optional Style of the line connecting the data points. 0 means no line (scatter plot). lineWidth: int, optional Width of the line connecting the data points. redraw_mode: int, optional WaveformCurveItem.REDRAW_ON_EITHER: (Default) Redraw after either X or Y receives new data. WaveformCurveItem.REDRAW_ON_X: Redraw after X receives new data. WaveformCurveItem.REDRAW_ON_Y: Redraw after Y receives new data. WaveformCurveItem.REDRAW_ON_BOTH: Redraw after both X and Y receive new data. symbol: str or None, optional Which symbol to use to represent the data. symbol: int, optional Size of the symbol. """ plot_opts = {} plot_opts['symbol'] = symbol if symbolSize is not None: plot_opts['symbolSize'] = symbolSize if lineStyle is not None: plot_opts['lineStyle'] = lineStyle if lineWidth is not None: plot_opts['lineWidth'] = lineWidth if redraw_mode is not None: plot_opts['redraw_mode'] = redraw_mode self._needs_redraw = False curve = WaveformCurveItem(y_addr=y_channel, x_addr=x_channel, name=name, color=color, **plot_opts) self.channel_pairs[(y_channel, x_channel)] = curve self.addCurve(curve, curve_color=color) curve.data_changed.connect(self.set_needs_redraw) def removeChannel(self, curve): """ Remove a curve from the plot. Parameters ---------- curve: WaveformCurveItem The curve to remove. """ self.removeCurve(curve) def removeChannelAtIndex(self, index): """ Remove a curve from the plot, given an index for a curve. Parameters ---------- index: int Index for the curve to remove. """ curve = self._curves[index] self.removeChannel(curve) @Slot() def set_needs_redraw(self): self._needs_redraw = True @Slot() def redrawPlot(self): """ Request a redraw from each curve in the plot. Called by curves when they get new data. """ if not self._needs_redraw: return for curve in self._curves: curve.redrawCurve() self._needs_redraw = False def clearCurves(self): """ Remove all curves from the plot. """ super(PyDMWaveformPlot, self).clear() def getCurves(self): """ Get a list of json representations for each curve. """ return [json.dumps(curve.to_dict()) for curve in self._curves] def setCurves(self, new_list): """ Replace all existing curves with new ones. This function is mostly used as a way to load curves from a .ui file, and almost all users will want to add curves through addChannel, not this method. Parameters ---------- new_list: list A list of json strings representing each curve in the plot. """ try: new_list = [json.loads(str(i)) for i in new_list] except ValueError as e: print("Error parsing curve json data: {}".format(e)) return self.clearCurves() for d in new_list: color = d.get('color') if color: color = QColor(color) self.addChannel(d['y_channel'], d['x_channel'], name=d.get('name'), color=color, lineStyle=d.get('lineStyle'), lineWidth=d.get('lineWidth'), symbol=d.get('symbol'), symbolSize=d.get('symbolSize'), redraw_mode=d.get('redraw_mode')) curves = Property("QStringList", getCurves, setCurves, designable=False) def channels(self): """ Returns the list of channels used by all curves in the plot. Returns ------- list """ chans = [] chans.extend([curve.y_channel for curve in self._curves]) chans.extend([ curve.x_channel for curve in self._curves if curve.x_channel is not None ]) return chans # The methods for autoRangeX, minXRange, maxXRange, autoRangeY, minYRange, # and maxYRange are all defined in BasePlot, but we don't expose them as # properties there, because not all plot subclasses necessarily want them # to be user-configurable in Designer. autoRangeX = Property(bool, BasePlot.getAutoRangeX, BasePlot.setAutoRangeX, BasePlot.resetAutoRangeX, doc=""" Whether or not the X-axis automatically rescales to fit the data. If true, the values in minXRange and maxXRange are ignored.""") minXRange = Property(float, BasePlot.getMinXRange, BasePlot.setMinXRange, doc=""" Minimum X-axis value visible on the plot.""") maxXRange = Property(float, BasePlot.getMaxXRange, BasePlot.setMaxXRange, doc=""" Maximum X-axis value visible on the plot.""") autoRangeY = Property(bool, BasePlot.getAutoRangeY, BasePlot.setAutoRangeY, BasePlot.resetAutoRangeY, doc=""" Whether or not the Y-axis automatically rescales to fit the data. If true, the values in minYRange and maxYRange are ignored.""") minYRange = Property(float, BasePlot.getMinYRange, BasePlot.setMinYRange, doc=""" Minimum Y-axis value visible on the plot.""") maxYRange = Property(float, BasePlot.getMaxYRange, BasePlot.setMaxYRange, doc=""" Maximum Y-axis value visible on the plot.""")
class SpinBox(QtGui.QAbstractSpinBox): """ **Bases:** QtGui.QAbstractSpinBox QSpinBox widget on steroids. Allows selection of numerical value, with extra features: - SI prefix notation (eg, automatically display "300 mV" instead of "0.003 V") - Float values with linear and decimal stepping (1-9, 10-90, 100-900, etc.) - Option for unbounded values - Delayed signals (allows multiple rapid changes with only one change signal) ============================= ============================================== **Signals:** valueChanged(value) Same as QSpinBox; emitted every time the value has changed. sigValueChanged(self) Emitted when value has changed, but also combines multiple rapid changes into one signal (eg, when rolling the mouse wheel). sigValueChanging(self, value) Emitted immediately for all value changes. ============================= ============================================== """ ## There's a PyQt bug that leaks a reference to the ## QLineEdit returned from QAbstractSpinBox.lineEdit() ## This makes it possible to crash the entire program ## by making accesses to the LineEdit after the spinBox has been deleted. ## I have no idea how to get around this.. valueChanged = QtCore.Signal( object) # (value) for compatibility with QSpinBox sigValueChanged = QtCore.Signal(object) # (self) sigValueChanging = QtCore.Signal( object, object) # (self, value) sent immediately; no delay. def __init__(self, parent=None, value=0.0, **kwargs): """ ============== ======================================================================== **Arguments:** parent Sets the parent widget for this SpinBox (optional). Default is None. value (float/int) initial value. Default is 0.0. bounds (min,max) Minimum and maximum values allowed in the SpinBox. Either may be None to leave the value unbounded. By default, values are unbounded. suffix (str) suffix (units) to display after the numerical value. By default, suffix is an empty str. siPrefix (bool) If True, then an SI prefix is automatically prepended to the units and the value is scaled accordingly. For example, if value=0.003 and suffix='V', then the SpinBox will display "300 mV" (but a call to SpinBox.value will still return 0.003). Default is False. step (float) The size of a single step. This is used when clicking the up/ down arrows, when rolling the mouse wheel, or when pressing keyboard arrows while the widget has keyboard focus. Note that the interpretation of this value is different when specifying the 'dec' argument. Default is 0.01. dec (bool) If True, then the step value will be adjusted to match the current size of the variable (for example, a value of 15 might step in increments of 1 whereas a value of 1500 would step in increments of 100). In this case, the 'step' argument is interpreted *relative* to the current value. The most common 'step' values when dec=True are 0.1, 0.2, 0.5, and 1.0. Default is False. minStep (float) When dec=True, this specifies the minimum allowable step size. int (bool) if True, the value is forced to integer type. Default is False decimals (int) Number of decimal values to display. Default is 2. readonly (bool) If True, then mouse and keyboard interactions are caught and will not produce a valueChanged signal, but the value can be still changed programmatic via the setValue method. Default is False. ============== ======================================================================== """ QtGui.QAbstractSpinBox.__init__(self, parent) self.lastValEmitted = None self.lastText = '' self.textValid = True ## If false, we draw a red border self.setMinimumWidth(0) self.setMaximumHeight(20) self.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Preferred) self.opts = { 'bounds': [None, None], ## Log scaling options #### Log mode is no longer supported. #'step': 0.1, #'minStep': 0.001, #'log': True, #'dec': False, ## decimal scaling option - example #'step': 0.1, #'minStep': .001, #'log': False, #'dec': True, ## normal arithmetic step 'step': D('0.01' ), ## if 'dec' is false, the spinBox steps by 'step' every time ## if 'dec' is True, the step size is relative to the value ## 'step' needs to be an integral divisor of ten, ie 'step'*n=10 for some integer value of n (but only if dec is True) 'log': False, 'dec': False, ## if true, does decimal stepping. ie from 1-10 it steps by 'step', from 10 to 100 it steps by 10*'step', etc. ## if true, minStep must be set in order to cross zero. 'int': False, ## Set True to force value to be integer 'suffix': '', 'siPrefix': False, ## Set to True to display numbers with SI prefix (ie, 100pA instead of 1e-10A) 'delay': 0.3, ## delay sending wheel update signals for 300ms 'delayUntilEditFinished': True, ## do not send signals until text editing has finished ## for compatibility with QDoubleSpinBox and QSpinBox 'decimals': 2, 'readonly': False, } self.decOpts = ['step', 'minStep'] self.val = D(asUnicode( value)) ## Value is precise decimal. Ordinary math not allowed. self.updateText() self.skipValidate = False self.setCorrectionMode(self.CorrectToPreviousValue) self.setKeyboardTracking(False) self.setOpts(**kwargs) self.editingFinished.connect(self.editingFinishedEvent) self.proxy = SignalProxy(self.sigValueChanging, slot=self.delayedChange, delay=self.opts['delay']) def event(self, ev): ret = QtGui.QAbstractSpinBox.event(self, ev) if ev.type() == QtCore.QEvent.KeyPress and ev.key( ) == QtCore.Qt.Key_Return: ret = True ## For some reason, spinbox pretends to ignore return key press #Fix: introduce the Escape event, which is restoring the previous display. if ev.type() == QtCore.QEvent.KeyPress and ev.key( ) == QtCore.Qt.Key_Escape: self.updateText() ret = True return ret ##lots of config options, just gonna stuff 'em all in here rather than do the get/set crap. def setOpts(self, **opts): """ Changes the behavior of the SpinBox. Accepts most of the arguments allowed in :func:`__init__ <pyqtgraph.SpinBox.__init__>`. """ #print opts for k in opts: if k == 'bounds': #print opts[k] self.setMinimum(opts[k][0], update=False) self.setMaximum(opts[k][1], update=False) #for i in [0,1]: #if opts[k][i] is None: #self.opts[k][i] = None #else: #self.opts[k][i] = D(unicode(opts[k][i])) elif k in ['step', 'minStep']: self.opts[k] = D(asUnicode(opts[k])) elif k == 'value': pass ## don't set value until bounds have been set else: self.opts[k] = opts[k] if 'value' in opts: self.setValue(opts['value']) ## If bounds have changed, update value to match if 'bounds' in opts and 'value' not in opts: self.setValue() ## sanity checks: if self.opts['int']: if 'step' in opts: step = opts['step'] ## not necessary.. #if int(step) != step: #raise Exception('Integer SpinBox must have integer step size.') else: self.opts['step'] = int(self.opts['step']) if 'minStep' in opts: step = opts['minStep'] if int(step) != step: raise Exception( 'Integer SpinBox must have integer minStep size.') else: ms = int(self.opts.get('minStep', 1)) if ms < 1: ms = 1 self.opts['minStep'] = ms if 'delay' in opts: self.proxy.setDelay(opts['delay']) if 'readonly' in opts: self.opts['readonly'] = opts['readonly'] self.updateText() #Fix: reimplement the methods for compatibility reasons to QSpinBox and QDoubleSpinBox methods def maximum(self): """ Reimplement the maximum functionality.""" max_val = self.opts['bounds'][1] if self.opts['int'] and max_val is not None: return int(max_val) elif max_val is not None: return float(max_val) else: return max_val def setMaximum(self, m, update=True): """Set the maximum allowed value (or None for no limit)""" if m is not None: #FIX: insert the integer functionality: if self.opts['int']: m = int(m) m = D(asUnicode(m)) self.opts['bounds'][1] = m if update: self.setValue() #Fix: reimplement the methods for compatibility reasons to QSpinBox and QDoubleSpinBox methods def minimum(self): """ Reimplement the minimum functionality.""" min_val = self.opts['bounds'][0] if self.opts['int'] and min_val is not None: return int(min_val) elif min_val is not None: return float(min_val) else: return min_val def setMinimum(self, m, update=True): """Set the minimum allowed value (or None for no limit)""" if m is not None: #FIX: insert the integer functionality: if self.opts['int']: m = int(m) m = D(asUnicode(m)) self.opts['bounds'][0] = m if update: self.setValue() def setPrefix(self, p): self.setOpts(prefix=p) def setRange(self, r0, r1): self.setOpts(bounds=[r0, r1]) def setProperty(self, prop, val): ## for QSpinBox compatibility if prop == 'value': #if type(val) is QtCore.QVariant: #val = val.toDouble()[0] self.setValue(val) else: print("Warning: SpinBox.setProperty('{0!s}', ..) not supported.". format(prop)) def setSuffix(self, suf): self.setOpts(suffix=suf) def setSingleStep(self, step): self.setOpts(step=step) def setDecimals(self, decimals): self.setOpts(decimals=decimals) def selectNumber(self): """ Select the numerical portion of the text to allow quick editing by the user. """ le = self.lineEdit() text = asUnicode(le.text()) if self.opts['suffix'] == '': le.setSelection(0, len(text)) else: try: index = text.index(' ') except ValueError: return le.setSelection(0, index) #Fix: reimplement the methods for compatibility reasons to QSpinBox and QDoubleSpinBox methods def suffix(self): return self.opts['suffix'] #Fix: reimplement the methods for compatibility reasons to QSpinBox and QDoubleSpinBox methods def prefix(self): return self.opts['prefix'] def value(self): """ Return the value of this SpinBox. """ if self.opts['int']: return int(self.val) else: return float(self.val) def setValue(self, value=None, update=True, delaySignal=False): """ Set the value of this spin. If the value is out of bounds, it will be clipped to the nearest boundary. If the spin is integer type, the value will be coerced to int. Returns the actual value set. If value is None, then the current value is used (this is for resetting the value after bounds, etc. have changed) """ if value is None: value = self.value() bounds = self.opts['bounds'] if bounds[0] is not None and value < bounds[0]: value = bounds[0] if bounds[1] is not None and value > bounds[1]: value = bounds[1] if self.opts['int']: value = int(value) value = D(asUnicode(value)) if value == self.val: return prev = self.val self.val = value if update: self.updateText(prev=prev) self.sigValueChanging.emit( self, float(self.val) ) ## change will be emitted in 300ms if there are no subsequent changes. if not delaySignal: self.emitChanged() return value value_float = Property(float, value, setValue, doc='Qt property value as type float') value_int = Property(int, value, setValue, doc='Qt property value as type int') def emitChanged(self): self.lastValEmitted = self.val self.valueChanged.emit(float(self.val)) self.sigValueChanged.emit(self) def delayedChange(self): try: if self.val != self.lastValEmitted: self.emitChanged() except RuntimeError: pass ## This can happen if we try to handle a delayed signal after someone else has already deleted the underlying C++ object. def widgetGroupInterface(self): return (self.valueChanged, SpinBox.value, SpinBox.setValue) def sizeHint(self): return QtCore.QSize(120, 0) def stepEnabled(self): return self.StepUpEnabled | self.StepDownEnabled #def fixup(self, *args): #print "fixup:", args def stepBy(self, n): n = D(int(n)) ## n must be integral number of steps. s = [D(-1), D(1)][n >= 0] ## determine sign of step val = self.val for i in range(int(abs(n))): if self.opts['log']: raise Exception("Log mode no longer supported.") # step = abs(val) * self.opts['step'] # if 'minStep' in self.opts: # step = max(step, self.opts['minStep']) # val += step * s if self.opts['dec']: if val == 0: step = self.opts['minStep'] exp = None else: vs = [D(-1), D(1)][val >= 0] #exp = D(int(abs(val*(D('1.01')**(s*vs))).log10())) fudge = D('1.01')**( s * vs ) ## fudge factor. at some places, the step size depends on the step sign. exp = abs(val * fudge).log10().quantize( 1, rounding=ROUND_FLOOR) step = self.opts['step'] * D(10)**exp if 'minStep' in self.opts: step = max(step, self.opts['minStep']) val += s * step #print "Exp:", exp, "step", step, "val", val else: val += s * self.opts['step'] if 'minStep' in self.opts and abs(val) < self.opts['minStep']: val = D(0) self.setValue( val, delaySignal=True ) ## note all steps (arrow buttons, wheel, up/down keys..) emit delayed signals only. def valueInRange(self, value): bounds = self.opts['bounds'] if bounds[0] is not None and value < bounds[0]: return False if bounds[1] is not None and value > bounds[1]: return False if self.opts.get('int', False): if int(value) != value: return False return True def updateText(self, prev=None): # print("Update text.") self.skipValidate = True if self.opts['siPrefix']: if self.val == 0 and prev is not None: (s, p) = fn.siScale(prev) txt = "0.0 {0!s}{1!s}".format(p, self.opts['suffix']) else: txt = fn.siFormat(float(self.val), precision=self.opts['decimals'] + 1, suffix=self.opts['suffix']) else: txt = '{0:.14g}{1!s}'.format(self.val, self.opts['suffix']) self.lineEdit().setText(txt) self.lastText = txt self.skipValidate = False def validate(self, strn, pos): # print('validate', strn, pos) if self.skipValidate: # print("skip validate") #self.textValid = False ret = QtGui.QValidator.Acceptable else: try: ## first make sure we didn't mess with the suffix suff = self.opts.get('suffix', '') # fix: if the whole text is selected and one needs to typ in a # new number, then a single integer character is ignored. if len(strn) == 1 and strn.isdigit(): scl_str = fn.siScale(self.val)[1] strn = '{0} {1}{2}'.format(strn, scl_str, suff) if len(suff) > 0 and asUnicode(strn)[-len(suff):] != suff: #print '"%s" != "%s"' % (unicode(strn)[-len(suff):], suff) ret = QtGui.QValidator.Invalid # print('invalid input', 'suff:', suff, '{0} != {1}'.format(asUnicode(strn)[-len(suff):], suff)) ## next see if we actually have an interpretable value else: val = self.interpret() if val is False: #print "can't interpret" #self.setStyleSheet('SpinBox {border: 2px solid #C55;}') #self.textValid = False ret = QtGui.QValidator.Intermediate else: if self.valueInRange(val): if not self.opts['delayUntilEditFinished']: self.setValue(val, update=False) #print " OK:", self.val #self.setStyleSheet('') #self.textValid = True ret = QtGui.QValidator.Acceptable else: ret = QtGui.QValidator.Intermediate except: #print " BAD" #import sys #sys.excepthook(*sys.exc_info()) #self.textValid = False #self.setStyleSheet('SpinBox {border: 2px solid #C55;}') ret = QtGui.QValidator.Intermediate ## draw / clear border if ret == QtGui.QValidator.Intermediate: self.textValid = False elif ret == QtGui.QValidator.Acceptable: self.textValid = True ## note: if text is invalid, we don't change the textValid flag ## since the text will be forced to its previous state anyway self.update() ## support 2 different pyqt APIs. Bleh. if hasattr(QtCore, 'QString'): return (ret, pos) else: return (ret, strn, pos) def paintEvent(self, ev): QtGui.QAbstractSpinBox.paintEvent(self, ev) ## draw red border if text is invalid if not self.textValid: p = QtGui.QPainter(self) p.setRenderHint(p.Antialiasing) p.setPen(fn.mkPen((200, 50, 50), width=2)) p.drawRoundedRect(self.rect().adjusted(2, 2, -2, -2), 4, 4) p.end() def interpret(self): """Return value of text. Return False if text is invalid, raise exception if text is intermediate""" strn = self.lineEdit().text() #Fix: strip leading blank characters, which produce errors: strn = strn.lstrip() suf = self.opts['suffix'] if len(suf) > 0: if strn[-len(suf):] != suf: return False #raise Exception("Units are invalid.") strn = strn[:-len(suf)] try: val = fn.siEval(strn) except: #sys.excepthook(*sys.exc_info()) #print "invalid" return False #print val return val #def interpretText(self, strn=None): #print "Interpret:", strn #if strn is None: #strn = self.lineEdit().text() #self.setValue(siEval(strn), update=False) ##QtGui.QAbstractSpinBox.interpretText(self) def editingFinishedEvent(self): """Edit has finished; set value.""" #print "Edit finished." if asUnicode(self.lineEdit().text()) == self.lastText: #print "no text change." return try: val = self.interpret() except: return if val is False: #print "value invalid:", str(self.lineEdit().text()) return if val == self.val: #print "no value change:", val, self.val return self.setValue( val, delaySignal=False ) ## allow text update so that values are reformatted pretty-like #def textChanged(self): #print "Text changed." ### Drop-in replacement for SpinBox; just for crash-testing #class SpinBox(QtGui.QDoubleSpinBox): #valueChanged = QtCore.Signal(object) # (value) for compatibility with QSpinBox #sigValueChanged = QtCore.Signal(object) # (self) #sigValueChanging = QtCore.Signal(object) # (value) #def __init__(self, parent=None, *args, **kargs): #QtGui.QSpinBox.__init__(self, parent) #def __getattr__(self, attr): #return lambda *args, **kargs: None #def widgetGroupInterface(self): #return (self.valueChanged, SpinBox.value, SpinBox.setValue) def isReadOnly(self): """ Overwrite the QAbstractSpinBox method to obtain the ReadOnly state. """ return self.opts['readonly'] def mousePressEvent(self, event): """ Handle what happens on press event of the mouse. @param event: QEvent of a Mouse Release action """ if self.isReadOnly(): event.accept() else: super(SpinBox, self).mousePressEvent(event) # Comment out this method, since it is called, if QToolTip is going to be # displayed. You would not see any QTooltip if you catch that signal. # def mouseMoveEvent(self, event): # """ Handle what happens on move (over) event of the mouse. # # @param event: QEvent of a Mouse Move action # """ # # if ( self.isReadOnly() ): # event.accept() # else: # super(SpinBox, self).mouseMoveEvent(event) def mouseReleaseEvent(self, event): """ Handle what happens on release of the mouse. @param event: QEvent of a Mouse Release action """ if self.isReadOnly(): event.accept() else: super(SpinBox, self).mouseReleaseEvent(event) # Handle event in which the widget has focus and the spacebar is pressed. def keyPressEvent(self, event): """ Handle what happens on keypress. @param event: QEvent of a key press Action """ if self.isReadOnly(): event.accept() else: super(SpinBox, self).keyPressEvent(event) def wheelEvent(self, event): """ Handle what happens on a wheel event of the mouse. @param event: QEvent of a Mouse Wheel event """ if self.isReadOnly(): event.accept() else: super(SpinBox, self).wheelEvent(event) @QtCore.pyqtSlot(bool) def setReadOnly(self, state): """ Overwrite the QAbstractSpinBox method to set the ReadOnly state. @param bool state: True or False, for having a readonly QRadioButton. Important, declare that slot as a qt slot to provide a C++ signature for that. The advantage is less memory consumption and slightly faster performance. Note: Not using the slot decorator causes that the signal connection mechanism is forced to work out manually the type conversion to map from the underlying C++ function signatures to the Python functions. When the slot decorators are used, the type mapping can be explicit! """ self.setOpts(readonly=state) readOnly = QtCore.pyqtProperty(bool, isReadOnly, setReadOnly)
class LabelBackend(QObject): @staticmethod def qml_point_to_np(qpoint: QPointF): return np.array(qpoint.toTuple()) @staticmethod def qml_rect_to_np(qrect: QRectF): return np.array([ qrect.topLeft().toTuple(), qrect.bottomRight().toTuple(), ]) def __init__(self): super().__init__() self.instances = [] self.instances_by_id = {} self.image_provider = LabelOverlayImageProvider() self.config = LabelConfig() # Semantic classes def load_config(self, cfg_path): if cfg_path.is_file(): self.config.load_from_path(cfg_path) else: print(f'Config path {cfg_path} is not a file') def set_image_path(self, img_path): print('Loading image', img_path) # Load new image self.img_path = Path(img_path) self.photo = imageio.imread(self.img_path) self.resolution = np.array(self.photo.shape[:2][::-1]) self.image_provider.init_image(self.resolution) self.overlay_data = self.image_provider.image_view # Clear instances for old_inst in self.instances: old_inst.deleted.emit() self.instances = [] self.instances_by_id = {} # Load state data_dir = self.img_path.with_suffix('.labels') if data_dir.is_dir(): print(f'Loading saved state from {data_dir}') self.load(data_dir) self.next_instance_id = int( np.max([0] + [inst.id for inst in self.instances]) + 1) self.instances_by_id = {inst.id: inst for inst in self.instances} self.instance_selected = None self.overlay_refresh_after_selection_change() @Slot(str) def set_image(self, path): path_prefix = "file://" if path.startswith(path_prefix): path = path[path_prefix.__len__():] self.set_image_path(path) @Slot(int, QPointF) def paint_circle(self, label_to_paint, center): try: # this has to finish, we don't want to break UI interaction #print('paint_circle!', label_to_paint, center) if self.instance_selected: center_pt = np.rint(center.toTuple()).astype(dtype=np.int) self.instance_selected.paint_circle(label_to_paint, center_pt) self.instance_selected.grab_cut_update() self.overlay_refresh_after_edit() else: print('paint_circle: no instance is selected') except Exception as e: print('Error in paint_circle:', e) traceback.print_exc() @Slot(int, QJSValue) def paint_polygon(self, label_to_paint, points): try: # this has to finish, we don't want to break UI interaction if self.instance_selected: points = np.array([p.toTuple() for p in points.toVariant()]) #print('paint_polygon!', label_to_paint, points) self.instance_selected.paint_polygon(label_to_paint, points) self.instance_selected.grab_cut_update() self.overlay_refresh_after_edit() else: print('paint_polygon: no instance is selected') except Exception as e: print('Error in paint_polygon:', e) traceback.print_exc() def overlay_refresh_after_selection_change(self): if self.instance_selected: self.overlay_data[:] = (0, 0, 0, 128) self.instance_selected.draw_overlay_edit_interface( self.overlay_data) else: self.overlay_data[:] = 0 for inst in self.instances: inst.draw_overlay_contour(self.overlay_data) self.overlayUpdated.emit() self.selectedUpdate.emit() def overlay_refresh_after_edit(self): if self.instance_selected: self.instance_selected.draw_overlay_edit_interface( self.overlay_data) self.overlayUpdated.emit() else: print('overlay_refresh_after_edit but instance_selected is null') @Slot(int) def select_instance(self, instance_id): if instance_id <= 0: instance_id = None if instance_id: self.instance_selected = self.instances_by_id[instance_id] else: self.instance_selected = None self.overlay_refresh_after_selection_change() @Slot(QRectF, int) def new_instance(self, roi_rect_qt, sem_class_id): try: # this has to finish, we don't want to break UI interaction roi_rect = np.rint(self.qml_rect_to_np(roi_rect_qt)).astype(np.int) sem_class = self.config.classes_by_id.get(sem_class_id, self.config.classes[0]) margin = 32 crop_rect = np.array([ np.maximum(roi_rect[0] - margin, 0), np.minimum(roi_rect[1] + margin, self.resolution), ]) # automatically mark existing instances as excluded from the new instance existing_instance_mask = np.zeros(tuple(self.resolution[::-1]), dtype=np.uint8) for inst in self.instances: inst.draw_mask(existing_instance_mask, 1) instance = GrabCutInstance(self.next_instance_id, sem_class, self.photo, crop_rect, roi_rect) self.next_instance_id += 1 instance.grab_cut_init(existing_instance_mask) self.instances.append(instance) self.instances_by_id[instance.id] = instance self.select_instance(instance.id) self.instanceAdded.emit(instance) except Exception as e: print('Error in new_instance:', e) traceback.print_exc() @Slot(int, int) def set_instance_class(self, instance_id, class_id): try: # this has to finish, we don't want to break UI interaction inst = self.instances_by_id[instance_id] cls = self.config.classes_by_id[class_id] inst.semantic_class = cls inst.update_qt_info() self.overlay_refresh_after_selection_change() except Exception as e: print('Error in set_instance_class:', e) traceback.print_exc() @Slot(int) def delete_instance(self, instance_id): try: # this has to finish, we don't want to break UI interaction inst = self.instances_by_id[instance_id] if self.instance_selected == inst: self.select_instance(0) del self.instances_by_id[instance_id] self.instances.remove(inst) inst.deleted.emit() self.overlay_refresh_after_selection_change() except Exception as e: print('Error in delete_instance:', e) traceback.print_exc() @Slot() def save(self): # outputs sem_map = np.zeros(tuple(self.resolution[::-1]), dtype=np.uint8) sem_colorimg = np.zeros(tuple(self.resolution[::-1]) + (3, ), dtype=np.uint8) inst_map = np.zeros(tuple(self.resolution[::-1]), dtype=np.uint8) for inst_id, inst in enumerate(self.instances): inst.draw_mask(sem_map) inst.draw_mask(sem_colorimg, inst.semantic_class.color) inst.draw_mask(inst_map, inst_id + 1) out_dir = self.img_path.with_suffix('.labels') out_dir.mkdir(exist_ok=True) imageio.imwrite(out_dir / 'labels_semantic.png', sem_map) imageio.imwrite(out_dir / 'labels_semantic_color.png', sem_colorimg) imageio.imwrite(out_dir / 'labels_instance.png', inst_map) # internal state json_data = dict(instances=[inst.to_dict() for inst in self.instances]) with (out_dir / 'index.json').open('w') as f_out: json.dump(json_data, f_out, indent=' ') for inst in self.instances: inst.save_to_dir(out_dir) def load(self, in_dir): with (in_dir / 'index.json').open('r') as f_in: json_data = json.load(f_in) self.instances = [ GrabCutInstance.from_dict(inst_data, self.config, self.photo) for inst_data in json_data['instances'] ] for inst in self.instances: inst.load_from_dir(in_dir) self.instanceAdded.emit(inst) # Expose to Qt overlayUpdated = Signal() instanceAdded = Signal(QObject) classesUpdated = Signal() classes = Property('QVariant', notify=classesUpdated) @classes.getter def get_classes(self): return self.config.to_simple_objects() @Slot(result='QVariant') def get_instances(self): return self.instances selectedUpdate = Signal() selected = Property(QObject, attrgetter('instance_selected'), notify=selectedUpdate)
class QLed(QFrame, ShapeMap): """QLed class.""" ShapeMap = ShapeMap Q_ENUMS(ShapeMap) abspath = _os.path.abspath(_os.path.dirname(__file__)) shapesdict = dict() f = QFile(_os.path.join(abspath, 'resources/led_shapes/circle.svg')) if f.open(QFile.ReadOnly): shapesdict[ShapeMap.Circle] = str(f.readAll(), 'utf-8') f.close() f = QFile(_os.path.join(abspath, 'resources/led_shapes/round.svg')) if f.open(QFile.ReadOnly): shapesdict[ShapeMap.Round] = str(f.readAll(), 'utf-8') f.close() f = QFile(_os.path.join(abspath, 'resources/led_shapes/square.svg')) if f.open(QFile.ReadOnly): shapesdict[ShapeMap.Square] = str(f.readAll(), 'utf-8') f.close() f = QFile(_os.path.join(abspath, 'resources/led_shapes/triangle.svg')) if f.open(QFile.ReadOnly): shapesdict[ShapeMap.Triangle] = str(f.readAll(), 'utf-8') f.close() Green = QColor(15, 105, 0) Red = QColor(207, 0, 0) Gray = QColor(90, 90, 90) SelColor = QColor(0, 0, 0) NotSelColor1 = QColor(251, 244, 252) NotSelColor2 = QColor(173, 173, 173) clicked = Signal() selected = Signal(bool) def __init__(self, parent=None, **kwargs): """Class constructor.""" super().__init__(parent, **kwargs) self.m_state = 0 self.m_stateColors = [self.Red, self.Green] self.m_dsblColor = self.Gray self.m_shape = self.ShapeMap.Circle self._pressed = False self._isselected = False self.renderer = QSvgRenderer() def getState(self): """Value property getter.""" return self.m_state def setState(self, value): """Value property setter.""" self.m_state = value self.update() state = Property(bool, getState, setState) def getOnColor(self): """On color property getter.""" return self.m_stateColors[1] def setOnColor(self, newColor): """On color property setter.""" self.m_stateColors[1] = newColor self.update() onColor = Property(QColor, getOnColor, setOnColor) def getOffColor(self): """Off color property getter.""" return self.m_stateColors[0] def setOffColor(self, newColor): """Off color property setter.""" self.m_stateColors[0] = newColor self.update() offColor = Property(QColor, getOffColor, setOffColor) @property def stateColors(self): """Color list property getter.""" return list(self.m_stateColors) @stateColors.setter def stateColors(self, new_colors): """Color list property setter.""" if not isinstance(new_colors, (list, tuple)) or\ len(new_colors) < 2 or not isinstance(new_colors[0], QColor): return self.m_stateColors = list(new_colors) def getDsblColor(self): """Disabled color property getter.""" return self.m_dsblColor def setDsblColor(self, newColor): """Disabled color property setter.""" self.m_dsblColor = newColor self.update() dsblColor = Property(QColor, getDsblColor, setDsblColor) def getShape(self): """Shape property getter.""" return self.m_shape def setShape(self, newShape): """Shape property setter.""" self.m_shape = newShape self.update() shape = Property(ShapeMap, getShape, setShape) def sizeHint(self): """Return the base size of the widget according to shape.""" if self.m_shape == self.ShapeMap.Triangle: return QSize(48, 36) elif self.m_shape == self.ShapeMap.Round: return QSize(72, 36) return QSize(36, 36) def adjust(self, r, g, b): """Adjust the color to set on svg code.""" def normalise(x): return x / 255.0 def denormalise(x): if x <= 1: return int(x * 255.0) else: return 255.0 (h, l, s) = rgb_to_hls(normalise(r), normalise(g), normalise(b)) (nr, ng, nb) = hls_to_rgb(h, l * 1.5, s) return (denormalise(nr), denormalise(ng), denormalise(nb)) def getRGBfromQColor(self, qcolor): """Convert QColors to a tupple of rgb colors to set on svg code.""" redhex = qcolor.red() greenhex = qcolor.green() bluehex = qcolor.blue() return (redhex, greenhex, bluehex) def paintEvent(self, event): """Handle appearence of the widget on state updates.""" self.style().unpolish(self) self.style().polish(self) option = QStyleOption() option.initFrom(self) h = option.rect.height() w = option.rect.width() if self.m_shape in (self.ShapeMap.Triangle, self.ShapeMap.Round): aspect = (4 / 3.0) if self.m_shape == self.ShapeMap.Triangle else 2.0 ah = w / aspect aw = w if ah > h: ah = h aw = h * aspect x = abs(aw - w) / 2.0 y = abs(ah - h) / 2.0 bounds = QRectF(x, y, aw, ah) else: size = min(w, h) x = abs(size - w) / 2.0 y = abs(size - h) / 2.0 bounds = QRectF(x, y, size, size) painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing, True) ind = self.m_state % len(self.m_stateColors) dark_r, dark_g, dark_b = self.getRGBfromQColor(self.m_stateColors[ind]) if not self.isEnabled(): dark_r, dark_g, dark_b = self.getRGBfromQColor(self.m_dsblColor) sel1_r, sel1_g, sel1_b = self.getRGBfromQColor(self.SelColor) sel2_r, sel2_g, sel2_b = self.getRGBfromQColor(self.SelColor) opc = '1.000' if not self.isSelected(): sel1_r, sel1_g, sel1_b = self.getRGBfromQColor(self.NotSelColor1) sel2_r, sel2_g, sel2_b = self.getRGBfromQColor(self.NotSelColor2) opc = '0.145' dark_str = "rgb(%d,%d,%d)" % (dark_r, dark_g, dark_b) light_str = "rgb(%d,%d,%d)" % self.adjust(dark_r, dark_g, dark_b) sel1_str = "rgb(%d,%d,%d)" % (sel1_r, sel1_g, sel1_b) sel2_str = "rgb(%d,%d,%d)" % (sel2_r, sel2_g, sel2_b) shape_bytes = bytes( self.shapesdict[self.m_shape] % (sel1_str, opc, sel2_str, dark_str, light_str), 'utf-8') self.renderer.load(QByteArray(shape_bytes)) self.renderer.render(painter, bounds) def mousePressEvent(self, event): """Handle mouse press event.""" self._pressed = True super().mousePressEvent(event) def mouseReleaseEvent(self, event): """Handle mouse release event.""" if self._pressed: self._pressed = False self.clicked.emit() super().mouseReleaseEvent(event) def toggleState(self): """Toggle state property.""" self.m_state = 0 if self.m_state else 1 self.update() def isSelected(self): """Return selected state of object.""" return self._isselected def setSelected(self, sel): """Configure selected state of object.""" self._isselected = bool(sel) self.selected.emit(self._isselected) self.update() def toggleSelected(self): """Toggle isSelected property.""" self.setSelected(not self.isSelected())
class ShellBaseWidget(ConsoleBaseWidget, SaveHistoryMixin, BrowseHistoryMixin): """ Shell base widget """ redirect_stdio = Signal(bool) sig_keyboard_interrupt = Signal() execute = Signal(str) append_to_history = Signal(str, str) def __init__(self, parent, history_filename, profile=False, initial_message=None, default_foreground_color=None, error_foreground_color=None, traceback_foreground_color=None, prompt_foreground_color=None, background_color=None): """ parent : specifies the parent widget """ ConsoleBaseWidget.__init__(self, parent) SaveHistoryMixin.__init__(self, history_filename) BrowseHistoryMixin.__init__(self) # Prompt position: tuple (line, index) self.current_prompt_pos = None self.new_input_line = True # History assert is_text_string(history_filename) self.history = self.load_history() # Session self.historylog_filename = CONF.get('main', 'historylog_filename', get_conf_path('history.log')) # Context menu self.menu = None self.setup_context_menu() # Simple profiling test self.profile = profile # Buffer to increase performance of write/flush operations self.__buffer = [] if initial_message: self.__buffer.append(initial_message) self.__timestamp = 0.0 self.__flushtimer = QTimer(self) self.__flushtimer.setSingleShot(True) self.__flushtimer.timeout.connect(self.flush) # Give focus to widget self.setFocus() # Cursor width self.setCursorWidth(CONF.get('main', 'cursor/width')) # Adjustments to completion_widget to use it here self.completion_widget.currentRowChanged.disconnect() def toggle_wrap_mode(self, enable): """Enable/disable wrap mode""" self.set_wrap_mode('character' if enable else None) def set_font(self, font): """Set shell styles font""" self.setFont(font) self.set_pythonshell_font(font) cursor = self.textCursor() cursor.select(QTextCursor.Document) charformat = QTextCharFormat() charformat.setFontFamily(font.family()) charformat.setFontPointSize(font.pointSize()) cursor.mergeCharFormat(charformat) #------ Context menu def setup_context_menu(self): """Setup shell context menu""" self.menu = QMenu(self) self.cut_action = create_action(self, _("Cut"), shortcut=keybinding('Cut'), icon=ima.icon('editcut'), triggered=self.cut) self.copy_action = create_action(self, _("Copy"), shortcut=keybinding('Copy'), icon=ima.icon('editcopy'), triggered=self.copy) paste_action = create_action(self, _("Paste"), shortcut=keybinding('Paste'), icon=ima.icon('editpaste'), triggered=self.paste) save_action = create_action(self, _("Save history log..."), icon=ima.icon('filesave'), tip=_( "Save current history log (i.e. all " "inputs and outputs) in a text file"), triggered=self.save_historylog) self.delete_action = create_action(self, _("Delete"), shortcut=keybinding('Delete'), icon=ima.icon('editdelete'), triggered=self.delete) selectall_action = create_action(self, _("Select All"), shortcut=keybinding('SelectAll'), icon=ima.icon('selectall'), triggered=self.selectAll) add_actions( self.menu, (self.cut_action, self.copy_action, paste_action, self.delete_action, None, selectall_action, None, save_action)) def contextMenuEvent(self, event): """Reimplement Qt method""" state = self.has_selected_text() self.copy_action.setEnabled(state) self.cut_action.setEnabled(state) self.delete_action.setEnabled(state) self.menu.popup(event.globalPos()) event.accept() #------ Input buffer def get_current_line_from_cursor(self): return self.get_text('cursor', 'eof') def _select_input(self): """Select current line (without selecting console prompt)""" line, index = self.get_position('eof') if self.current_prompt_pos is None: pline, pindex = line, index else: pline, pindex = self.current_prompt_pos self.setSelection(pline, pindex, line, index) @Slot() def clear_terminal(self): """ Clear terminal window Child classes reimplement this method to write prompt """ self.clear() # The buffer being edited def _set_input_buffer(self, text): """Set input buffer""" if self.current_prompt_pos is not None: self.replace_text(self.current_prompt_pos, 'eol', text) else: self.insert(text) self.set_cursor_position('eof') def _get_input_buffer(self): """Return input buffer""" input_buffer = '' if self.current_prompt_pos is not None: input_buffer = self.get_text(self.current_prompt_pos, 'eol') input_buffer = input_buffer.replace(os.linesep, '\n') return input_buffer input_buffer = Property("QString", _get_input_buffer, _set_input_buffer) #------ Prompt def new_prompt(self, prompt): """ Print a new prompt and save its (line, index) position """ if self.get_cursor_line_column()[1] != 0: self.write('\n') self.write(prompt, prompt=True) # now we update our cursor giving end of prompt self.current_prompt_pos = self.get_position('cursor') self.ensureCursorVisible() self.new_input_line = False def check_selection(self): """ Check if selected text is r/w, otherwise remove read-only parts of selection """ if self.current_prompt_pos is None: self.set_cursor_position('eof') else: self.truncate_selection(self.current_prompt_pos) #------ Copy / Keyboard interrupt @Slot() def copy(self): """Copy text to clipboard... or keyboard interrupt""" if self.has_selected_text(): ConsoleBaseWidget.copy(self) elif not sys.platform == 'darwin': self.interrupt() def interrupt(self): """Keyboard interrupt""" self.sig_keyboard_interrupt.emit() @Slot() def cut(self): """Cut text""" self.check_selection() if self.has_selected_text(): ConsoleBaseWidget.cut(self) @Slot() def delete(self): """Remove selected text""" self.check_selection() if self.has_selected_text(): ConsoleBaseWidget.remove_selected_text(self) @Slot() def save_historylog(self): """Save current history log (all text in console)""" title = _("Save history log") self.redirect_stdio.emit(False) filename, _selfilter = getsavefilename( self, title, self.historylog_filename, "%s (*.log)" % _("History logs")) self.redirect_stdio.emit(True) if filename: filename = osp.normpath(filename) try: encoding.write(to_text_string(self.get_text_with_eol()), filename) self.historylog_filename = filename CONF.set('main', 'historylog_filename', filename) except EnvironmentError: pass #------ Basic keypress event handler def on_enter(self, command): """on_enter""" self.execute_command(command) def execute_command(self, command): self.execute.emit(command) self.add_to_history(command) self.new_input_line = True def on_new_line(self): """On new input line""" self.set_cursor_position('eof') self.current_prompt_pos = self.get_position('cursor') self.new_input_line = False @Slot() def paste(self): """Reimplemented slot to handle multiline paste action""" if self.new_input_line: self.on_new_line() ConsoleBaseWidget.paste(self) def keyPressEvent(self, event): """ Reimplement Qt Method Basic keypress event handler (reimplemented in InternalShell to add more sophisticated features) """ if self.preprocess_keyevent(event): # Event was accepted in self.preprocess_keyevent return self.postprocess_keyevent(event) def preprocess_keyevent(self, event): """Pre-process keypress event: return True if event is accepted, false otherwise""" # Copy must be done first to be able to copy read-only text parts # (otherwise, right below, we would remove selection # if not on current line) ctrl = event.modifiers() & Qt.ControlModifier meta = event.modifiers() & Qt.MetaModifier # meta=ctrl in OSX if event.key() == Qt.Key_C and \ ((Qt.MetaModifier | Qt.ControlModifier) & event.modifiers()): if meta and sys.platform == 'darwin': self.interrupt() elif ctrl: self.copy() event.accept() return True if self.new_input_line and ( len(event.text()) or event.key() in \ (Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right) ): self.on_new_line() return False def postprocess_keyevent(self, event): """Post-process keypress event: in InternalShell, this is method is called when shell is ready""" event, text, key, ctrl, shift = restore_keyevent(event) # Is cursor on the last line? and after prompt? if len(text): #XXX: Shouldn't it be: `if len(unicode(text).strip(os.linesep))` ? if self.has_selected_text(): self.check_selection() self.restrict_cursor_position(self.current_prompt_pos, 'eof') cursor_position = self.get_position('cursor') if key in (Qt.Key_Return, Qt.Key_Enter): if self.is_cursor_on_last_line(): self._key_enter() # add and run selection else: self.insert_text(self.get_selected_text(), at_end=True) elif key == Qt.Key_Insert and not shift and not ctrl: self.setOverwriteMode(not self.overwriteMode()) elif key == Qt.Key_Delete: if self.has_selected_text(): self.check_selection() self.remove_selected_text() elif self.is_cursor_on_last_line(): self.stdkey_clear() elif key == Qt.Key_Backspace: self._key_backspace(cursor_position) elif key == Qt.Key_Tab: self._key_tab() elif key == Qt.Key_Space and ctrl: self._key_ctrl_space() elif key == Qt.Key_Left: if self.current_prompt_pos == cursor_position: # Avoid moving cursor on prompt return method = self.extend_selection_to_next if shift \ else self.move_cursor_to_next method('word' if ctrl else 'character', direction='left') elif key == Qt.Key_Right: if self.is_cursor_at_end(): return method = self.extend_selection_to_next if shift \ else self.move_cursor_to_next method('word' if ctrl else 'character', direction='right') elif (key == Qt.Key_Home) or ((key == Qt.Key_Up) and ctrl): self._key_home(shift, ctrl) elif (key == Qt.Key_End) or ((key == Qt.Key_Down) and ctrl): self._key_end(shift, ctrl) elif key == Qt.Key_Up: if not self.is_cursor_on_last_line(): self.set_cursor_position('eof') y_cursor = self.get_coordinates(cursor_position)[1] y_prompt = self.get_coordinates(self.current_prompt_pos)[1] if y_cursor > y_prompt: self.stdkey_up(shift) else: self.browse_history(backward=True) elif key == Qt.Key_Down: if not self.is_cursor_on_last_line(): self.set_cursor_position('eof') y_cursor = self.get_coordinates(cursor_position)[1] y_end = self.get_coordinates('eol')[1] if y_cursor < y_end: self.stdkey_down(shift) else: self.browse_history(backward=False) elif key in (Qt.Key_PageUp, Qt.Key_PageDown): #XXX: Find a way to do this programmatically instead of calling # widget keyhandler (this won't work if the *event* is coming from # the event queue - i.e. if the busy buffer is ever implemented) ConsoleBaseWidget.keyPressEvent(self, event) elif key == Qt.Key_Escape and shift: self.clear_line() elif key == Qt.Key_Escape: self._key_escape() elif key == Qt.Key_L and ctrl: self.clear_terminal() elif key == Qt.Key_V and ctrl: self.paste() elif key == Qt.Key_X and ctrl: self.cut() elif key == Qt.Key_Z and ctrl: self.undo() elif key == Qt.Key_Y and ctrl: self.redo() elif key == Qt.Key_A and ctrl: self.selectAll() elif key == Qt.Key_Question and not self.has_selected_text(): self._key_question(text) elif key == Qt.Key_ParenLeft and not self.has_selected_text(): self._key_parenleft(text) elif key == Qt.Key_Period and not self.has_selected_text(): self._key_period(text) elif len(text) and not self.isReadOnly(): self.hist_wholeline = False self.insert_text(text) self._key_other(text) else: # Let the parent widget handle the key press event ConsoleBaseWidget.keyPressEvent(self, event) #------ Key handlers def _key_enter(self): command = self.input_buffer self.insert_text('\n', at_end=True) self.on_enter(command) self.flush() def _key_other(self, text): raise NotImplementedError def _key_backspace(self, cursor_position): raise NotImplementedError def _key_tab(self): raise NotImplementedError def _key_ctrl_space(self): raise NotImplementedError def _key_home(self, shift, ctrl): if self.is_cursor_on_last_line(): self.stdkey_home(shift, ctrl, self.current_prompt_pos) def _key_end(self, shift, ctrl): if self.is_cursor_on_last_line(): self.stdkey_end(shift, ctrl) def _key_pageup(self): raise NotImplementedError def _key_pagedown(self): raise NotImplementedError def _key_escape(self): raise NotImplementedError def _key_question(self, text): raise NotImplementedError def _key_parenleft(self, text): raise NotImplementedError def _key_period(self, text): raise NotImplementedError #------ History Management def load_history(self): """Load history from a .py file in user home directory""" if osp.isfile(self.history_filename): rawhistory, _ = encoding.readlines(self.history_filename) rawhistory = [line.replace('\n', '') for line in rawhistory] if rawhistory[1] != self.INITHISTORY[1]: rawhistory[1] = self.INITHISTORY[1] else: rawhistory = self.INITHISTORY history = [line for line in rawhistory \ if line and not line.startswith('#')] # Truncating history to X entries: while len(history) >= CONF.get('historylog', 'max_entries'): del history[0] while rawhistory[0].startswith('#'): del rawhistory[0] del rawhistory[0] # Saving truncated history: try: encoding.writelines(rawhistory, self.history_filename) except EnvironmentError: pass return history #------ Simulation standards input/output def write_error(self, text): """Simulate stderr""" self.flush() self.write(text, flush=True, error=True) if get_debug_level(): STDERR.write(text) def write(self, text, flush=False, error=False, prompt=False): """Simulate stdout and stderr""" if prompt: self.flush() if not is_string(text): # This test is useful to discriminate QStrings from decoded str text = to_text_string(text) self.__buffer.append(text) ts = time.time() if flush or prompt: self.flush(error=error, prompt=prompt) elif ts - self.__timestamp > 0.05: self.flush(error=error) self.__timestamp = ts # Timer to flush strings cached by last write() operation in series self.__flushtimer.start(50) def flush(self, error=False, prompt=False): """Flush buffer, write text to console""" # Fix for spyder-ide/spyder#2452 if PY3: try: text = "".join(self.__buffer) except TypeError: text = b"".join(self.__buffer) try: text = text.decode(locale.getdefaultlocale()[1]) except: pass else: text = "".join(self.__buffer) self.__buffer = [] self.insert_text(text, at_end=True, error=error, prompt=prompt) # The lines below are causing a hard crash when Qt generates # internal warnings. We replaced them instead for self.update(), # which prevents the crash. # See spyder-ide/spyder#10893 # QCoreApplication.processEvents() # self.repaint() self.update() # Clear input buffer: self.new_input_line = True #------ Text Insertion def insert_text(self, text, at_end=False, error=False, prompt=False): """ Insert text at the current cursor position or at the end of the command line """ if at_end: # Insert text at the end of the command line self.append_text_to_shell(text, error, prompt) else: # Insert text at current cursor position ConsoleBaseWidget.insert_text(self, text) #------ Re-implemented Qt Methods def focusNextPrevChild(self, next): """ Reimplemented to stop Tab moving to the next window """ if next: return False return ConsoleBaseWidget.focusNextPrevChild(self, next) #------ Drag and drop def dragEnterEvent(self, event): """Drag and Drop - Enter event""" event.setAccepted(event.mimeData().hasFormat("text/plain")) def dragMoveEvent(self, event): """Drag and Drop - Move event""" if (event.mimeData().hasFormat("text/plain")): event.setDropAction(Qt.MoveAction) event.accept() else: event.ignore() def dropEvent(self, event): """Drag and Drop - Drop event""" if (event.mimeData().hasFormat("text/plain")): text = to_text_string(event.mimeData().text()) if self.new_input_line: self.on_new_line() self.insert_text(text, at_end=True) self.setFocus() event.setDropAction(Qt.MoveAction) event.accept() else: event.ignore() def drop_pathlist(self, pathlist): """Drop path list""" raise NotImplementedError
class MTag(QLabel): """ Tag for categorizing or markup. """ sig_closed = Signal() sig_clicked = Signal() def __init__(self, text="", parent=None): super(MTag, self).__init__(text=text, parent=parent) self._is_pressed = False self._close_button = MToolButton().tiny().svg( "close_line.svg").icon_only() self._close_button.clicked.connect(self.sig_closed) self._close_button.clicked.connect(self.close) self._close_button.setVisible(False) self._main_lay = QHBoxLayout() self._main_lay.setContentsMargins(0, 0, 0, 0) self._main_lay.addStretch() self._main_lay.addWidget(self._close_button) self.setLayout(self._main_lay) self._clickable = False self._border = True self._border_style = QssTemplate(""" MTag{ font-size: @font_size_base@font_unit; padding: @padding_small@unit; color: @text_color; border-radius: @border_radius; border: 1px solid @border_color; background-color: @background_color; } MTag:hover{ color: @hover_color; } """) self._no_border_style = QssTemplate(""" MTag{ font-size: @font_size_base@font_unit; padding: @padding@unit; border-radius: @border_radius; color: @text_color; border: 0 solid @border_color; background-color: @background_color; } MTag:hover{ background-color:@hover_color; } """) self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) self._color = None self.set_dayu_color(dayu_theme.secondary_text_color) def minimumSizeHint(self, *args, **kwargs): """Override minimumSizeHint for expand width when the close button is visible.""" orig = super(MTag, self).minimumSizeHint(*args, **kwargs) orig.setWidth(orig.width() + ( dayu_theme.tiny if self._close_button.isVisible() else 0)) return orig def get_dayu_color(self): """Get tag's color""" return self._color def set_dayu_color(self, value): """Set Tag primary color.""" self._color = value self._update_style() def _update_style(self): scale_x, _ = get_scale_factor() if self._border: self.setStyleSheet( self._border_style.substitute( padding_small=3 * scale_x, font_size_base=dayu_theme.font_size_base, font_unit=dayu_theme.font_unit, unit=dayu_theme.unit, background_color=utils.fade_color(self._color, "15%"), border_radius=dayu_theme.border_radius_base, border_color=utils.fade_color(self._color, "35%"), hover_color=utils.generate_color(self._color, 5), text_color=self._color, )) else: self.setStyleSheet( self._no_border_style.substitute( padding=4 * scale_x, font_size_base=dayu_theme.font_size_base, font_unit=dayu_theme.font_unit, unit=dayu_theme.unit, background_color=utils.generate_color(self._color, 6), border_radius=dayu_theme.border_radius_base, border_color=utils.generate_color(self._color, 6), hover_color=utils.generate_color(self._color, 5), text_color=dayu_theme.text_color_inverse, )) dayu_color = Property(str, get_dayu_color, set_dayu_color) def mousePressEvent(self, event): """Override mousePressEvent to flag _is_pressed.""" if event.button() == Qt.LeftButton: self._is_pressed = True return super(MTag, self).mousePressEvent(event) def leaveEvent(self, event): """Override leaveEvent to reset _is_pressed flag.""" self._is_pressed = False return super(MTag, self).leaveEvent(event) def mouseReleaseEvent(self, event): """Override mouseReleaseEvent to emit sig_clicked signal.""" if event.button() == Qt.LeftButton and self._is_pressed: if self._clickable: self.sig_clicked.emit() self._is_pressed = False return super(MTag, self).mouseReleaseEvent(event) def closeable(self): """Set Tag can be closed and show the close icon button.""" self._close_button.setVisible(True) return self def clickable(self): """Set Tag can be clicked and change the cursor to pointing-hand shape when enter.""" self.setCursor(Qt.PointingHandCursor) self._clickable = True return self def no_border(self): """Set Tag style is border or fill.""" self._border = False self._update_style() return self def coloring(self, color): """Same as set_dayu_color. Support chain.""" self.set_dayu_color(color) return self
class TyphosSignalPanel(TyphosBase, TyphosDesignerMixin, SignalOrder): """ Panel of Signals for a given device, using :class:`SignalPanel`. Parameters ---------- parent : QtWidgets.QWidget, optional The parent widget. init_channel : str, optional The PyDM channel with which to initialize the widget. """ Q_ENUMS(SignalOrder) # Necessary for display in Designer SignalOrder = SignalOrder # For convenience # From top of page to bottom kind_order = (Kind.hinted, Kind.normal, Kind.config, Kind.omitted) _panel_class = SignalPanel updated = QtCore.Signal() _kind_to_property = { 'hinted': 'showHints', 'normal': 'showNormal', 'config': 'showConfig', 'omitted': 'showOmitted', } def __init__(self, parent=None, init_channel=None): super().__init__(parent=parent) # Create a SignalPanel layout to be modified later self._panel_layout = self._panel_class() self.setLayout(self._panel_layout) self._name_filter = '' # Add default Kind values self._kinds = dict.fromkeys([kind.name for kind in Kind], True) self._signal_order = SignalOrder.byKind self.setContextMenuPolicy(QtCore.Qt.DefaultContextMenu) self.contextMenuEvent = self.open_context_menu def _get_kind(self, kind): """Property getter for show[kind].""" return self._kinds[kind] def _set_kind(self, value, kind): """Property setter for show[kind] = value.""" # If we have a new value store it if value != self._kinds[kind]: # Store it internally self._kinds[kind] = value # Remodify the layout for the new Kind self._update_panel() @property def filter_settings(self): """Get the filter settings dictionary.""" return dict( name_filter=self.nameFilter, kinds=self.show_kinds, ) def _update_panel(self): """Apply filters and emit the update signal.""" self._panel_layout.filter_signals(**self.filter_settings) self.updated.emit() @property def show_kinds(self): """Get a list of the :class:`ophyd.Kind` that should be shown.""" return [kind for kind in Kind if self._kinds[kind.name]] # Kind Configuration pyqtProperty showHints = Property(bool, partial(_get_kind, kind='hinted'), partial(_set_kind, kind='hinted'), doc='Show ophyd.Kind.hinted signals') showNormal = Property(bool, partial(_get_kind, kind='normal'), partial(_set_kind, kind='normal'), doc='Show ophyd.Kind.normal signals') showConfig = Property(bool, partial(_get_kind, kind='config'), partial(_set_kind, kind='config'), doc='Show ophyd.Kind.config signals') showOmitted = Property(bool, partial(_get_kind, kind='omitted'), partial(_set_kind, kind='omitted'), doc='Show ophyd.Kind.omitted signals') @Property(str) def nameFilter(self): """Get or set the current name filter.""" return self._name_filter @nameFilter.setter def nameFilter(self, name_filter): if name_filter != self._name_filter: self._name_filter = name_filter.strip() self._update_panel() @Property(SignalOrder) def sortBy(self): """Get or set the order that the signals will be placed in layout.""" return self._signal_order @sortBy.setter def sortBy(self, value): if value != self._signal_order: self._signal_order = value self._update_panel() def add_device(self, device): """Typhos hook for adding a new device.""" self.devices.clear() super().add_device(device) # Configure the layout for the new device self._panel_layout.add_device(device) self._update_panel() def set_device_display(self, display): """Typhos hook for when the TyphosDeviceDisplay is associated.""" self.display = display def generate_context_menu(self): """Generate a context menu for this TyphosSignalPanel.""" menu = QtWidgets.QMenu(parent=self) menu.addSection('Kinds') for kind, property_name in self._kind_to_property.items(): def selected(new_value, *, name=property_name): setattr(self, name, new_value) action = menu.addAction('Show &' + kind) action.setCheckable(True) action.setChecked(getattr(self, property_name)) action.triggered.connect(selected) return menu def open_context_menu(self, ev): """ Open a context menu when the Default Context Menu is requested. Parameters ---------- ev : QEvent """ menu = self.generate_context_menu() menu.exec_(self.mapToGlobal(ev.pos()))
class PyDMAlarmTree(QTreeView, PyDMWritableWidget): def __init__(self, parent, init_channel=None, config_name=None, edit_mode=False): super(PyDMAlarmTree, self).__init__() QTreeView.__init__(self, parent) PyDMWritableWidget.__init__(self) self.setup_ui() self._nodes = [] self.config_name = config_name self.tree_model = AlarmTreeModel(self) self.setModel(self.tree_model) self.edit_mode = edit_mode self.setContextMenuPolicy(Qt.CustomContextMenu) if not edit_mode: self.customContextMenuRequested.connect(self._open_menu) self.expandAll() def setup_ui(self): self.setEditTriggers(QAbstractItemView.NoEditTriggers) self.setDragDropOverwriteMode(False) self.setSelectionMode(QAbstractItemView.SingleSelection) self.setSelectionBehavior(QAbstractItemView.SelectRows) # self.setHeaderHidden(True) self.setColumnWidth(0, 160) self.setColumnWidth(1, 160) self.setColumnWidth(2, 160) def get_configuration_name(self): return self.config_name def set_configuration_name(self, config_name): self.config_name = config_name configuration_name = Property(str, get_configuration_name, set_configuration_name, designable=False) def _open_menu(self, point): menu = QMenu() index = self.indexAt(point) item = self.model().getItem(index) self.value_action = QAction(item.status, self) self.value_action.setEnabled(False) menu.addAction(self.value_action) self.acknowledge_action = QAction("Acknowledge", self) self.acknowledge_action.triggered.connect( partial(self._acknowledge_at_index, index)) menu.addAction(self.acknowledge_action) self.remove_acknowledge_action = QAction("Remove Acknowledge", self) self.remove_acknowledge_action.triggered.connect( partial(self._remove_acknowledge_at_index, index)) self.remove_acknowledge_action.setEnabled(False) menu.addAction(self.remove_acknowledge_action) menu.exec_(self.viewport().mapToGlobal(point)) def _acknowledge_at_index(self, index): item = self.tree_model.getItem(index) item.acknowledge() def _remove_acknowledge_at_index(self, index): item = self.tree_model.getItem(index) item.unacknowledge() def mousePressEvent(self, event): self.clearSelection() self.selectionModel().reset() QTreeView.mousePressEvent(self, event)
class PyDMTabWidget(QTabWidget): """PyDMTabWidget provides a tabbed container widget. Each tab has an alarm channel property which can be used to show an alarm indicator on the tab. The indicator is driven by the alarm severity of the specified channel, not the value. Parameters ---------- parent : QWidget The parent widget for the Tab Widget """ def __init__(self, parent=None): super(PyDMTabWidget, self).__init__(parent=parent) self.tb = PyDMTabBar(parent=self) self.setTabBar(self.tb) @Property(str) def currentTabAlarmChannel(self): """ A channel to use for the current tab's alarm indicator. Returns ------- str """ return self.tabBar().currentTabAlarmChannel @currentTabAlarmChannel.setter def currentTabAlarmChannel(self, new_alarm_channel): self.tabBar().currentTabAlarmChannel = new_alarm_channel def channels(self): """ A list of the channels used by the tab widget. Returns ------- list """ return self.tabBar().channels() def getAlarmChannels(self): """alarmChannels is a property used to store the configuration of this tab bar when it has been created in Qt Designer. This property isn't directly editable by users, they will go through the currentTabAlarmChannel property to edit this information.""" return self.tabBar().getAlarmChannels() def setAlarmChannels(self, new_alarm_channels): """ Sets the list of alarm channels for each tab. This is needed for instantiating a tab widget from a .ui file, and is probably not very useful for users. """ self.tabBar().setAlarmChannels(new_alarm_channels) @Property(QColor) def noAlarmIconColor(self): """ A color to use for alarm-sensitive tabs that have PyDMWidget.ALARM_NONE severity level. This property can be defined in a stylesheet by using 'qproperty-noAlarmIconColor'. Returns ------- QColor """ return self.tabBar().noAlarmIconColor @noAlarmIconColor.setter def noAlarmIconColor(self, new_color): if self.tabBar().noAlarmIconColor != new_color: self.tabBar().noAlarmIconColor = new_color self.tabBar().generate_alarm_icons() @Property(QColor) def minorAlarmIconColor(self): """ A color to use for alarm-sensitive tabs that have PyDMWidget.ALARM_MINOR severity level. This property can be defined in a stylesheet by using 'qproperty-minorAlarmIconColor'. Returns ------- QColor """ return self.tabBar().minorAlarmIconColor @minorAlarmIconColor.setter def minorAlarmIconColor(self, new_color): self.tabBar().minorAlarmIconColor = new_color @Property(QColor) def majorAlarmIconColor(self): """ A color to use for alarm-sensitive tabs that have PyDMWidget.ALARM_MAJOR severity level. This property can be defined in a stylesheet by using 'qproperty-majorAlarmIconColor'. Returns ------- QColor """ return self.tabBar().majorAlarmIconColor @majorAlarmIconColor.setter def majorAlarmIconColor(self, new_color): self.tabBar().majorAlarmIconColor = new_color @Property(QColor) def invalidAlarmIconColor(self): """ A color to use for alarm-sensitive tabs that have PyDMWidget.ALARM_INVALID severity level. This property can be defined in a stylesheet by using 'qproperty-majorAlarmIconColor'. Returns ------- QColor """ return self.tabBar().invalidAlarmIconColor @invalidAlarmIconColor.setter def invalidAlarmIconColor(self, new_color): self.tabBar().invalidAlarmIconColor = new_color @Property(QColor) def disconnectedAlarmIconColor(self): """ A color to use for alarm-sensitive tabs that have PyDMWidget.ALARM_DISCONNECTED severity level. This property can be defined in a stylesheet by using 'qproperty-disconnectedAlarmIconColor'. Returns ------- QColor """ return self.tabBar().disconnectedAlarmIconColor @disconnectedAlarmIconColor.setter def disconnectedAlarmIconColor(self, new_color): self.tabBar().disconnectedAlarmIconColor = new_color alarmChannels = Property("QStringList", getAlarmChannels, setAlarmChannels, designable=False) # We make a bunch of dummy properties to block out properties available on QTabWidget, # but that we don't want to support on PyDMTabWidget. currentTabIcon = Property("QIcon", None, None, designable=False) documentMode = Property(bool, None, None, designable=False) tabsClosable = Property(bool, None, None, designable=False) movable = Property(bool, None, None, designable=False)
class TyphonSignalPanel(TyphonBase, TyphonDesignerMixin, SignalOrder): """ Panel of Signals for Device """ Q_ENUMS(SignalOrder) # Necessary for display in Designer SignalOrder = SignalOrder # For convenience # From top of page to bottom kind_order = (Kind.hinted, Kind.normal, Kind.config, Kind.omitted) def __init__(self, parent=None, init_channel=None): super().__init__(parent=parent) # Create a SignalPanel layout to be modified later self.setLayout(SignalPanel()) # Add default Kind values self._kinds = dict.fromkeys([kind.name for kind in Kind], True) self._signal_order = SignalOrder.byKind def _get_kind(self, kind): return self._kinds[kind] def _set_kind(self, value, kind): # If we have a new value store it if value != self._kinds[kind]: # Store it internally self._kinds[kind] = value # Remodify the layout for the new Kind self._set_layout() # Kind Configuration pyqtProperty showHints = Property(bool, partial(_get_kind, kind='hinted'), partial(_set_kind, kind='hinted')) showNormal = Property(bool, partial(_get_kind, kind='normal'), partial(_set_kind, kind='normal')) showConfig = Property(bool, partial(_get_kind, kind='config'), partial(_set_kind, kind='config')) showOmitted = Property(bool, partial(_get_kind, kind='omitted'), partial(_set_kind, kind='omitted')) @Property(SignalOrder) def sortBy(self): """Order signals will be placed in layout""" return self._signal_order @sortBy.setter def sortBy(self, value): if value != self._signal_order: self._signal_order = value self._set_layout() def add_device(self, device): """Add a device to the widget""" # Only allow a single device self.devices.clear() # Add the new device super().add_device(device) # Configure the layout for the new device self._set_layout() def _set_layout(self): """Set the layout based on the current device and kind""" # We can't set a layout if we don't have any devices if not self.devices: return # Clear our layout self.layout().clear() shown_kind = [kind for kind in Kind if self._kinds[kind.name]] # Iterate through kinds signals = list() for kind in Kind: if kind in shown_kind: try: for (attr, signal) in grab_kind(self.devices[0], kind.name): label = clean_attr(attr) # Check twice for Kind as signal might have multiple # kinds if signal.kind in shown_kind: signals.append((label, signal)) except Exception: logger.exception("Unable to add %s signals from %r", kind.name, self.devices[0]) # Pick our sorting function if self._signal_order == SignalOrder.byKind: # Sort by kind def sorter(x): return self.kind_order.index(x[1].kind) elif self._signal_order == SignalOrder.byName: # Sort by name def sorter(x): return x[0] else: logger.exception("Unknown sorting type %r", self.sortBy) return # Add to layout for (label, signal) in sorted(set(signals), key=sorter): self.layout().add_signal(signal, label) def sizeHint(self): """Default SizeHint""" return QSize(240, 140)