def _test_visibility(self, view: StickyGraphicsView) -> None: header = view.headerView() footer = view.footerView() vsbar = view.verticalScrollBar() vsbar.triggerAction(vsbar.SliderToMinimum) self._ensure_laid_out(view) self.assertFalse(header.isVisibleTo(view)) self.assertTrue(footer.isVisibleTo(view)) vsbar.triggerAction(vsbar.SliderSingleStepAdd) self._ensure_laid_out(view) self.assertTrue(header.isVisibleTo(view)) self.assertTrue(footer.isVisibleTo(view)) vsbar.triggerAction(vsbar.SliderToMaximum) self._ensure_laid_out(view) self.assertTrue(header.isVisibleTo(view)) self.assertFalse(footer.isVisibleTo(view)) vsbar.triggerAction(vsbar.SliderSingleStepSub) self._ensure_laid_out(view) if not view.style().styleHint(QStyle.SH_ScrollBar_Transient, None, vsbar): # cannot reliably test due to QTBUG-65074 self.assertTrue(header.isVisibleTo(view)) self.assertTrue(footer.isVisibleTo(view))
class OWSilhouettePlot(widget.OWWidget): name = "Silhouette Plot" description = "Visually assess cluster quality and " \ "the degree of cluster membership." icon = "icons/SilhouettePlot.svg" priority = 300 keywords = [] class Inputs: data = Input("Data", (Orange.data.Table, Orange.misc.DistMatrix)) class Outputs: selected_data = Output("Selected Data", Orange.data.Table, default=True) annotated_data = Output(ANNOTATED_DATA_SIGNAL_NAME, Orange.data.Table) replaces = [ "orangecontrib.prototypes.widgets.owsilhouetteplot.OWSilhouettePlot", "Orange.widgets.unsupervised.owsilhouetteplot.OWSilhouettePlot" ] settingsHandler = settings.PerfectDomainContextHandler() #: Distance metric index distance_idx = settings.Setting(0) #: Group/cluster variable index cluster_var_idx = settings.ContextSetting(0) #: Annotation variable index annotation_var_idx = settings.ContextSetting(0) #: Group the (displayed) silhouettes by cluster group_by_cluster = settings.Setting(True) #: A fixed size for an instance bar bar_size = settings.Setting(3) #: Add silhouette scores to output data auto_commit = settings.Setting(True) pending_selection = settings.Setting(None, schema_only=True) Distances = [("Euclidean", Orange.distance.Euclidean), ("Manhattan", Orange.distance.Manhattan), ("Cosine", Orange.distance.Cosine)] graph_name = "scene" class Error(widget.OWWidget.Error): need_two_clusters = Msg("Need at least two non-empty clusters") singleton_clusters_all = Msg("All clusters are singletons") memory_error = Msg("Not enough memory") value_error = Msg("Distances could not be computed: '{}'") input_validation_error = Msg("{}") class Warning(widget.OWWidget.Warning): missing_cluster_assignment = Msg( "{} instance{s} omitted (missing cluster assignment)") nan_distances = Msg("{} instance{s} omitted (undefined distances)") ignoring_categorical = Msg("Ignoring categorical features") def __init__(self): super().__init__() #: The input data self.data = None # type: Optional[Orange.data.Table] #: The input distance matrix (if present) self.distances = None # type: Optional[Orange.misc.DistMatrix] #: The effective distance matrix (is self.distances or computed from #: self.data depending on input) self._matrix = None # type: Optional[Orange.misc.DistMatrix] #: An bool mask (size == len(data)) indicating missing group/cluster #: assignments self._mask = None # type: Optional[np.ndarray] #: An array of cluster/group labels for instances with valid group #: assignment self._labels = None # type: Optional[np.ndarray] #: An array of silhouette scores for instances with valid group #: assignment self._silhouette = None # type: Optional[np.ndarray] self._silplot = None # type: Optional[SilhouettePlot] controllayout = self.controlArea.layout() assert isinstance(controllayout, QVBoxLayout) self._distances_gui_box = distbox = gui.widgetBox( None, "Distance" ) self._distances_gui_cb = gui.comboBox( distbox, self, "distance_idx", items=[name for name, _ in OWSilhouettePlot.Distances], orientation=Qt.Horizontal, callback=self._invalidate_distances) controllayout.addWidget(distbox) box = gui.vBox(self.controlArea, "Cluster Label") self.cluster_var_cb = gui.comboBox( box, self, "cluster_var_idx", contentsLength=14, searchable=True, callback=self._invalidate_scores ) gui.checkBox( box, self, "group_by_cluster", "Group by cluster", callback=self._replot) self.cluster_var_model = itemmodels.VariableListModel(parent=self) self.cluster_var_cb.setModel(self.cluster_var_model) box = gui.vBox(self.controlArea, "Bars") gui.widgetLabel(box, "Bar width:") gui.hSlider( box, self, "bar_size", minValue=1, maxValue=10, step=1, callback=self._update_bar_size) gui.widgetLabel(box, "Annotations:") self.annotation_cb = gui.comboBox( box, self, "annotation_var_idx", contentsLength=14, callback=self._update_annotations) self.annotation_var_model = itemmodels.VariableListModel(parent=self) self.annotation_var_model[:] = ["None"] self.annotation_cb.setModel(self.annotation_var_model) ibox = gui.indentedBox(box, 5) self.ann_hidden_warning = warning = gui.widgetLabel( ibox, "(increase the width to show)") ibox.setFixedWidth(ibox.sizeHint().width()) warning.setVisible(False) gui.rubber(self.controlArea) gui.auto_send(self.buttonsArea, self, "auto_commit") self.scene = GraphicsScene(self) self.view = StickyGraphicsView(self.scene) self.view.setRenderHint(QPainter.Antialiasing, True) self.view.setAlignment(Qt.AlignTop | Qt.AlignLeft) self.mainArea.layout().addWidget(self.view) self.settingsAboutToBePacked.connect(self.pack_settings) def sizeHint(self): sh = self.controlArea.sizeHint() return sh.expandedTo(QSize(600, 720)) def pack_settings(self): if self.data and self._silplot is not None: self.pending_selection = list(self._silplot.selection()) else: self.pending_selection = None @Inputs.data @check_sql_input def set_data(self, data: Union[Table, DistMatrix, None]): """ Set the input dataset or distance matrix. """ self.closeContext() self.clear() try: if isinstance(data, Orange.misc.DistMatrix): self._set_distances(data) elif isinstance(data, Orange.data.Table): self._set_table(data) else: self.distances = None self.data = None except InputValidationError as err: self.Error.input_validation_error(err.message) self.distances = None self.data = None def _set_table(self, data: Table): self._setup_control_models(data.domain) self.data = data self.distances = None def _set_distances(self, distances: DistMatrix): if isinstance(distances.row_items, Orange.data.Table) and \ distances.axis == 1: data = distances.row_items else: raise ValidationError("Input matrix does not have associated data") if data is not None: self._setup_control_models(data.domain) self.distances = distances self.data = data def handleNewSignals(self): if not self._is_empty(): self._update() self._replot() if self.pending_selection is not None and self._silplot is not None: # If selection contains indices that are too large, the data # file must had been modified, so we ignore selection if max(self.pending_selection, default=-1) < len(self.data): self._silplot.setSelection(np.array(self.pending_selection)) self.pending_selection = None # Disable/enable the Distances GUI controls if applicable self._distances_gui_box.setEnabled(self.distances is None) self.commit.now() def _setup_control_models(self, domain: Domain): groupvars = [ v for v in domain.variables + domain.metas if v.is_discrete and len(v.values) >= 2] if not groupvars: raise NoGroupVariable() self.cluster_var_model[:] = groupvars if domain.class_var in groupvars: self.cluster_var_idx = groupvars.index(domain.class_var) else: self.cluster_var_idx = 0 annotvars = [var for var in domain.variables + domain.metas if var.is_string or var.is_discrete] self.annotation_var_model[:] = ["None"] + annotvars self.annotation_var_idx = 1 if annotvars else 0 self.openContext(Orange.data.Domain(groupvars)) def _is_empty(self) -> bool: # Is empty (does not have any input). return (self.data is None or len(self.data) == 0) \ and self.distances is None def clear(self): """ Clear the widget state. """ self.data = None self.distances = None self._matrix = None self._mask = None self._silhouette = None self._labels = None self.cluster_var_model[:] = [] self.annotation_var_model[:] = ["None"] self._clear_scene() self.Error.clear() self.Warning.clear() def _clear_scene(self): # Clear the graphics scene and associated objects self.scene.clear() self.scene.setSceneRect(QRectF()) self.view.setSceneRect(QRectF()) self.view.setHeaderSceneRect(QRectF()) self.view.setFooterSceneRect(QRectF()) self._silplot = None def _invalidate_distances(self): # Invalidate the computed distance matrix and recompute the silhouette. self._matrix = None self._invalidate_scores() def _invalidate_scores(self): # Invalidate and recompute the current silhouette scores. self._labels = self._silhouette = self._mask = None self._update() self._replot() if self.data is not None: self.commit.deferred() def _ensure_matrix(self): # ensure self._matrix is computed if necessary if self._is_empty(): return if self._matrix is None: if self.distances is not None: self._matrix = np.asarray(self.distances) elif self.data is not None: data = self.data _, metric = self.Distances[self.distance_idx] if not metric.supports_discrete and any( a.is_discrete for a in data.domain.attributes): self.Warning.ignoring_categorical() data = Orange.distance.remove_discrete_features(data) try: self._matrix = np.asarray(metric(data)) except MemoryError: self.Error.memory_error() return except ValueError as err: self.Error.value_error(str(err)) return else: assert False, "invalid state" def _update(self): # Update/recompute the effective distances and scores as required. self._clear_messages() if self._is_empty(): self._reset_all() return self._ensure_matrix() if self._matrix is None: return labelvar = self.cluster_var_model[self.cluster_var_idx] labels, _ = self.data.get_column_view(labelvar) labels = np.asarray(labels, dtype=float) cluster_mask = np.isnan(labels) dist_mask = np.isnan(self._matrix).all(axis=0) mask = cluster_mask | dist_mask labels = labels.astype(int) labels = labels[~mask] labels_unq = np.unique(labels) if len(labels_unq) < 2: self.Error.need_two_clusters() labels = silhouette = mask = None elif len(labels_unq) == len(labels): self.Error.singleton_clusters_all() labels = silhouette = mask = None else: silhouette = sklearn.metrics.silhouette_samples( self._matrix[~mask, :][:, ~mask], labels, metric="precomputed") self._mask = mask self._labels = labels self._silhouette = silhouette if mask is not None: count_missing = np.count_nonzero(cluster_mask) if count_missing: self.Warning.missing_cluster_assignment( count_missing, s="s" if count_missing > 1 else "") count_nandist = np.count_nonzero(dist_mask) if count_nandist: self.Warning.nan_distances( count_nandist, s="s" if count_nandist > 1 else "") def _reset_all(self): self._mask = None self._silhouette = None self._labels = None self._matrix = None self._clear_scene() def _clear_messages(self): self.Error.clear() self.Warning.clear() def _set_bar_height(self): visible = self.bar_size >= 5 self._silplot.setBarHeight(self.bar_size) self._silplot.setRowNamesVisible(visible) self.ann_hidden_warning.setVisible( not visible and self.annotation_var_idx > 0) def _replot(self): # Clear and replot/initialize the scene self._clear_scene() if self._silhouette is not None and self._labels is not None: var = self.cluster_var_model[self.cluster_var_idx] self._silplot = silplot = SilhouettePlot() self._set_bar_height() if self.group_by_cluster: silplot.setScores(self._silhouette, self._labels, var.values, var.colors) else: silplot.setScores( self._silhouette, np.zeros(len(self._silhouette), dtype=int), [""], np.array([[63, 207, 207]]) ) self.scene.addItem(silplot) self._update_annotations() silplot.selectionChanged.connect(self.commit.deferred) silplot.layout().activate() self._update_scene_rect() silplot.geometryChanged.connect(self._update_scene_rect) def _update_bar_size(self): if self._silplot is not None: self._set_bar_height() def _update_annotations(self): if 0 < self.annotation_var_idx < len(self.annotation_var_model): annot_var = self.annotation_var_model[self.annotation_var_idx] else: annot_var = None self.ann_hidden_warning.setVisible( self.bar_size < 5 and annot_var is not None) if self._silplot is not None: if annot_var is not None: column, _ = self.data.get_column_view(annot_var) if self._mask is not None: assert column.shape == self._mask.shape # pylint: disable=invalid-unary-operand-type column = column[~self._mask] self._silplot.setRowNames( [annot_var.str_val(value) for value in column]) else: self._silplot.setRowNames(None) def _update_scene_rect(self): geom = self._silplot.geometry() self.scene.setSceneRect(geom) self.view.setSceneRect(geom) header = self._silplot.topScaleItem() footer = self._silplot.bottomScaleItem() def extend_horizontal(rect): # type: (QRectF) -> QRectF rect = QRectF(rect) rect.setLeft(geom.left()) rect.setRight(geom.right()) return rect margin = 3 if header is not None: self.view.setHeaderSceneRect( extend_horizontal(header.geometry().adjusted(0, 0, 0, margin))) if footer is not None: self.view.setFooterSceneRect( extend_horizontal(footer.geometry().adjusted(0, -margin, 0, 0))) @gui.deferred def commit(self): """ Commit/send the current selection to the output. """ selected = indices = data = None if self.data is not None: selectedmask = np.full(len(self.data), False, dtype=bool) if self._silplot is not None: indices = self._silplot.selection() assert (np.diff(indices) > 0).all(), "strictly increasing" if self._mask is not None: # pylint: disable=invalid-unary-operand-type indices = np.flatnonzero(~self._mask)[indices] selectedmask[indices] = True if self._mask is not None: scores = np.full(shape=selectedmask.shape, fill_value=np.nan) # pylint: disable=invalid-unary-operand-type scores[~self._mask] = self._silhouette else: scores = self._silhouette var = self.cluster_var_model[self.cluster_var_idx] domain = self.data.domain proposed = "Silhouette ({})".format(escape(var.name)) names = [var.name for var in itertools.chain(domain.attributes, domain.class_vars, domain.metas)] unique = get_unique_names(names, proposed) silhouette_var = Orange.data.ContinuousVariable(unique) domain = Orange.data.Domain( domain.attributes, domain.class_vars, domain.metas + (silhouette_var, )) if np.count_nonzero(selectedmask): selected = self.data.from_table( domain, self.data, np.flatnonzero(selectedmask)) if selected is not None: with selected.unlocked(selected.metas): selected[:, silhouette_var] = np.c_[scores[selectedmask]] data = self.data.transform(domain) with data.unlocked(data.metas): data[:, silhouette_var] = np.c_[scores] self.Outputs.selected_data.send(selected) self.Outputs.annotated_data.send(create_annotated_table(data, indices)) def send_report(self): if not len(self.cluster_var_model): return self.report_plot() caption = "Silhouette plot ({} distance), clustered by '{}'".format( self.Distances[self.distance_idx][0], self.cluster_var_model[self.cluster_var_idx]) if self.annotation_var_idx and self._silplot.rowNamesVisible(): caption += ", annotated with '{}'".format( self.annotation_var_model[self.annotation_var_idx]) self.report_caption(caption) def onDeleteWidget(self): self.clear() super().onDeleteWidget()
def __init__(self): super().__init__() #: The input data self.data = None # type: Optional[Orange.data.Table] #: The input distance matrix (if present) self.distances = None # type: Optional[Orange.misc.DistMatrix] #: The effective distance matrix (is self.distances or computed from #: self.data depending on input) self._matrix = None # type: Optional[Orange.misc.DistMatrix] #: An bool mask (size == len(data)) indicating missing group/cluster #: assignments self._mask = None # type: Optional[np.ndarray] #: An array of cluster/group labels for instances with valid group #: assignment self._labels = None # type: Optional[np.ndarray] #: An array of silhouette scores for instances with valid group #: assignment self._silhouette = None # type: Optional[np.ndarray] self._silplot = None # type: Optional[SilhouettePlot] controllayout = self.controlArea.layout() assert isinstance(controllayout, QVBoxLayout) self._distances_gui_box = distbox = gui.widgetBox( None, "Distance" ) self._distances_gui_cb = gui.comboBox( distbox, self, "distance_idx", items=[name for name, _ in OWSilhouettePlot.Distances], orientation=Qt.Horizontal, callback=self._invalidate_distances) controllayout.addWidget(distbox) box = gui.vBox(self.controlArea, "Cluster Label") self.cluster_var_cb = gui.comboBox( box, self, "cluster_var_idx", contentsLength=14, searchable=True, callback=self._invalidate_scores ) gui.checkBox( box, self, "group_by_cluster", "Group by cluster", callback=self._replot) self.cluster_var_model = itemmodels.VariableListModel(parent=self) self.cluster_var_cb.setModel(self.cluster_var_model) box = gui.vBox(self.controlArea, "Bars") gui.widgetLabel(box, "Bar width:") gui.hSlider( box, self, "bar_size", minValue=1, maxValue=10, step=1, callback=self._update_bar_size) gui.widgetLabel(box, "Annotations:") self.annotation_cb = gui.comboBox( box, self, "annotation_var_idx", contentsLength=14, callback=self._update_annotations) self.annotation_var_model = itemmodels.VariableListModel(parent=self) self.annotation_var_model[:] = ["None"] self.annotation_cb.setModel(self.annotation_var_model) ibox = gui.indentedBox(box, 5) self.ann_hidden_warning = warning = gui.widgetLabel( ibox, "(increase the width to show)") ibox.setFixedWidth(ibox.sizeHint().width()) warning.setVisible(False) gui.rubber(self.controlArea) gui.auto_send(self.buttonsArea, self, "auto_commit") self.scene = GraphicsScene(self) self.view = StickyGraphicsView(self.scene) self.view.setRenderHint(QPainter.Antialiasing, True) self.view.setAlignment(Qt.AlignTop | Qt.AlignLeft) self.mainArea.layout().addWidget(self.view) self.settingsAboutToBePacked.connect(self.pack_settings)
def test(self): view = StickyGraphicsView() scene = QGraphicsScene(view) scene.setBackgroundBrush(QBrush(Qt.lightGray, Qt.CrossPattern)) view.setScene(scene) scene.addRect(QRectF(0, 0, 300, 20), Qt.red, QBrush(Qt.red, Qt.BDiagPattern)) scene.addRect(QRectF(0, 25, 300, 100)) scene.addRect(QRectF(0, 130, 300, 20), Qt.darkGray, QBrush(Qt.darkGray, Qt.BDiagPattern)) view.setHeaderSceneRect(QRectF(0, 0, 300, 20)) view.setFooterSceneRect(QRectF(0, 130, 300, 20)) header = view.headerView() footer = view.footerView() view.resize(310, 310) view.grab() self.assertFalse(header.isVisibleTo(view)) self.assertFalse(footer.isVisibleTo(view)) view.resize(310, 100) view.verticalScrollBar().setValue(0) # scroll to top view.grab() self.assertFalse(header.isVisibleTo(view)) self.assertTrue(footer.isVisibleTo(view)) view.verticalScrollBar().setValue( view.verticalScrollBar().maximum()) # scroll to bottom view.grab() self.assertTrue(header.isVisibleTo(view)) self.assertFalse(footer.isVisibleTo(view)) qWheelScroll(header.viewport(), angleDelta=QPoint(0, -720 * 8))
def __init__(self): super().__init__() self.matrix = None self.items = None self.linkmatrix = None self.root = None self._displayed_root = None self.cutoff_height = 0.0 gui.comboBox(self.controlArea, self, "linkage", items=LINKAGE, box="Linkage", callback=self._invalidate_clustering) model = itemmodels.VariableListModel() model[:] = self.basic_annotations box = gui.widgetBox(self.controlArea, "Annotations") self.label_cb = cb = combobox.ComboBoxSearch( minimumContentsLength=14, sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLengthWithIcon) cb.setModel(model) cb.setCurrentIndex(cb.findData(self.annotation, Qt.EditRole)) def on_annotation_activated(): self.annotation = cb.currentData(Qt.EditRole) self._update_labels() cb.activated.connect(on_annotation_activated) def on_annotation_changed(value): cb.setCurrentIndex(cb.findData(value, Qt.EditRole)) self.connect_control("annotation", on_annotation_changed) box.layout().addWidget(self.label_cb) box = gui.radioButtons(self.controlArea, self, "pruning", box="Pruning", callback=self._invalidate_pruning) grid = QGridLayout() box.layout().addLayout(grid) grid.addWidget(gui.appendRadioButton(box, "None", addToLayout=False), 0, 0) self.max_depth_spin = gui.spin(box, self, "max_depth", minv=1, maxv=100, callback=self._invalidate_pruning, keyboardTracking=False, addToLayout=False) grid.addWidget( gui.appendRadioButton(box, "Max depth:", addToLayout=False), 1, 0) grid.addWidget(self.max_depth_spin, 1, 1) self.selection_box = gui.radioButtons( self.controlArea, self, "selection_method", box="Selection", callback=self._selection_method_changed) grid = QGridLayout() self.selection_box.layout().addLayout(grid) grid.addWidget( gui.appendRadioButton(self.selection_box, "Manual", addToLayout=False), 0, 0) grid.addWidget( gui.appendRadioButton(self.selection_box, "Height ratio:", addToLayout=False), 1, 0) self.cut_ratio_spin = gui.spin(self.selection_box, self, "cut_ratio", 0, 100, step=1e-1, spinType=float, callback=self._selection_method_changed, addToLayout=False) self.cut_ratio_spin.setSuffix("%") grid.addWidget(self.cut_ratio_spin, 1, 1) grid.addWidget( gui.appendRadioButton(self.selection_box, "Top N:", addToLayout=False), 2, 0) self.top_n_spin = gui.spin(self.selection_box, self, "top_n", 1, 20, callback=self._selection_method_changed, addToLayout=False) grid.addWidget(self.top_n_spin, 2, 1) self.zoom_slider = gui.hSlider(self.controlArea, self, "zoom_factor", box="Zoom", minValue=-6, maxValue=3, step=1, ticks=True, createLabel=False, callback=self.__update_font_scale) zoom_in = QAction("Zoom in", self, shortcut=QKeySequence.ZoomIn, triggered=self.__zoom_in) zoom_out = QAction("Zoom out", self, shortcut=QKeySequence.ZoomOut, triggered=self.__zoom_out) zoom_reset = QAction("Reset zoom", self, shortcut=QKeySequence(Qt.ControlModifier | Qt.Key_0), triggered=self.__zoom_reset) self.addActions([zoom_in, zoom_out, zoom_reset]) self.controlArea.layout().addStretch() gui.auto_send(self.buttonsArea, self, "autocommit") self.scene = QGraphicsScene(self) self.view = StickyGraphicsView( self.scene, horizontalScrollBarPolicy=Qt.ScrollBarAlwaysOff, verticalScrollBarPolicy=Qt.ScrollBarAlwaysOn, alignment=Qt.AlignLeft | Qt.AlignVCenter) self.mainArea.layout().setSpacing(1) self.mainArea.layout().addWidget(self.view) def axis_view(orientation): ax = AxisItem(orientation=orientation, maxTickLength=7) ax.mousePressed.connect(self._activate_cut_line) ax.mouseMoved.connect(self._activate_cut_line) ax.mouseReleased.connect(self._activate_cut_line) ax.setRange(1.0, 0.0) return ax self.top_axis = axis_view("top") self.bottom_axis = axis_view("bottom") self._main_graphics = QGraphicsWidget() scenelayout = QGraphicsGridLayout() scenelayout.setHorizontalSpacing(10) scenelayout.setVerticalSpacing(10) self._main_graphics.setLayout(scenelayout) self.scene.addItem(self._main_graphics) self.dendrogram = DendrogramWidget() self.dendrogram.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) self.dendrogram.selectionChanged.connect(self._invalidate_output) self.dendrogram.selectionEdited.connect(self._selection_edited) self.labels = TextListWidget() self.labels.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred) self.labels.setAlignment(Qt.AlignLeft) self.labels.setMaximumWidth(200) scenelayout.addItem(self.top_axis, 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) scenelayout.addItem(self.dendrogram, 1, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) scenelayout.addItem(self.labels, 1, 1, alignment=Qt.AlignLeft | Qt.AlignVCenter) scenelayout.addItem(self.bottom_axis, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) self.view.viewport().installEventFilter(self) self._main_graphics.installEventFilter(self) self.top_axis.setZValue(self.dendrogram.zValue() + 10) self.bottom_axis.setZValue(self.dendrogram.zValue() + 10) self.cut_line = SliderLine(self.top_axis, orientation=Qt.Horizontal) self.cut_line.valueChanged.connect(self._dendrogram_slider_changed) self.dendrogram.geometryChanged.connect(self._dendrogram_geom_changed) self._set_cut_line_visible(self.selection_method == 1) self.__update_font_scale()
class OWHierarchicalClustering(widget.OWWidget): name = "Hierarchical Clustering" description = "Display a dendrogram of a hierarchical clustering " \ "constructed from the input distance matrix." icon = "icons/HierarchicalClustering.svg" priority = 2100 keywords = [] class Inputs: distances = Input("Distances", Orange.misc.DistMatrix) class Outputs: selected_data = Output("Selected Data", Orange.data.Table, default=True) annotated_data = Output(ANNOTATED_DATA_SIGNAL_NAME, Orange.data.Table) settingsHandler = _DomainContextHandler() #: Selected linkage linkage = settings.Setting(1) #: Index of the selected annotation item (variable, ...) annotation = settings.ContextSetting("Enumeration") #: Out-of-context setting for the case when the "Name" option is available annotation_if_names = settings.Setting("Name") #: Out-of-context setting for the case with just "Enumerate" and "None" annotation_if_enumerate = settings.Setting("Enumerate") #: Selected tree pruning (none/max depth) pruning = settings.Setting(0) #: Maximum depth when max depth pruning is selected max_depth = settings.Setting(10) #: Selected cluster selection method (none, cut distance, top n) selection_method = settings.Setting(0) #: Cut height ratio wrt root height cut_ratio = settings.Setting(75.0) #: Number of top clusters to select top_n = settings.Setting(3) #: Dendrogram zoom factor zoom_factor = settings.Setting(0) autocommit = settings.Setting(True) graph_name = "scene" basic_annotations = ["None", "Enumeration"] class Error(widget.OWWidget.Error): not_finite_distances = Msg("Some distances are infinite") #: Stored (manual) selection state (from a saved workflow) to restore. __pending_selection_restore = None # type: Optional[SelectionState] def __init__(self): super().__init__() self.matrix = None self.items = None self.linkmatrix = None self.root = None self._displayed_root = None self.cutoff_height = 0.0 gui.comboBox(self.controlArea, self, "linkage", items=LINKAGE, box="Linkage", callback=self._invalidate_clustering) model = itemmodels.VariableListModel() model[:] = self.basic_annotations box = gui.widgetBox(self.controlArea, "Annotations") self.label_cb = cb = combobox.ComboBoxSearch( minimumContentsLength=14, sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLengthWithIcon) cb.setModel(model) cb.setCurrentIndex(cb.findData(self.annotation, Qt.EditRole)) def on_annotation_activated(): self.annotation = cb.currentData(Qt.EditRole) self._update_labels() cb.activated.connect(on_annotation_activated) def on_annotation_changed(value): cb.setCurrentIndex(cb.findData(value, Qt.EditRole)) self.connect_control("annotation", on_annotation_changed) box.layout().addWidget(self.label_cb) box = gui.radioButtons(self.controlArea, self, "pruning", box="Pruning", callback=self._invalidate_pruning) grid = QGridLayout() box.layout().addLayout(grid) grid.addWidget(gui.appendRadioButton(box, "None", addToLayout=False), 0, 0) self.max_depth_spin = gui.spin(box, self, "max_depth", minv=1, maxv=100, callback=self._invalidate_pruning, keyboardTracking=False, addToLayout=False) grid.addWidget( gui.appendRadioButton(box, "Max depth:", addToLayout=False), 1, 0) grid.addWidget(self.max_depth_spin, 1, 1) self.selection_box = gui.radioButtons( self.controlArea, self, "selection_method", box="Selection", callback=self._selection_method_changed) grid = QGridLayout() self.selection_box.layout().addLayout(grid) grid.addWidget( gui.appendRadioButton(self.selection_box, "Manual", addToLayout=False), 0, 0) grid.addWidget( gui.appendRadioButton(self.selection_box, "Height ratio:", addToLayout=False), 1, 0) self.cut_ratio_spin = gui.spin(self.selection_box, self, "cut_ratio", 0, 100, step=1e-1, spinType=float, callback=self._selection_method_changed, addToLayout=False) self.cut_ratio_spin.setSuffix("%") grid.addWidget(self.cut_ratio_spin, 1, 1) grid.addWidget( gui.appendRadioButton(self.selection_box, "Top N:", addToLayout=False), 2, 0) self.top_n_spin = gui.spin(self.selection_box, self, "top_n", 1, 20, callback=self._selection_method_changed, addToLayout=False) grid.addWidget(self.top_n_spin, 2, 1) self.zoom_slider = gui.hSlider(self.controlArea, self, "zoom_factor", box="Zoom", minValue=-6, maxValue=3, step=1, ticks=True, createLabel=False, callback=self.__update_font_scale) zoom_in = QAction("Zoom in", self, shortcut=QKeySequence.ZoomIn, triggered=self.__zoom_in) zoom_out = QAction("Zoom out", self, shortcut=QKeySequence.ZoomOut, triggered=self.__zoom_out) zoom_reset = QAction("Reset zoom", self, shortcut=QKeySequence(Qt.ControlModifier | Qt.Key_0), triggered=self.__zoom_reset) self.addActions([zoom_in, zoom_out, zoom_reset]) self.controlArea.layout().addStretch() gui.auto_send(self.buttonsArea, self, "autocommit") self.scene = QGraphicsScene(self) self.view = StickyGraphicsView( self.scene, horizontalScrollBarPolicy=Qt.ScrollBarAlwaysOff, verticalScrollBarPolicy=Qt.ScrollBarAlwaysOn, alignment=Qt.AlignLeft | Qt.AlignVCenter) self.mainArea.layout().setSpacing(1) self.mainArea.layout().addWidget(self.view) def axis_view(orientation): ax = AxisItem(orientation=orientation, maxTickLength=7) ax.mousePressed.connect(self._activate_cut_line) ax.mouseMoved.connect(self._activate_cut_line) ax.mouseReleased.connect(self._activate_cut_line) ax.setRange(1.0, 0.0) return ax self.top_axis = axis_view("top") self.bottom_axis = axis_view("bottom") self._main_graphics = QGraphicsWidget() scenelayout = QGraphicsGridLayout() scenelayout.setHorizontalSpacing(10) scenelayout.setVerticalSpacing(10) self._main_graphics.setLayout(scenelayout) self.scene.addItem(self._main_graphics) self.dendrogram = DendrogramWidget() self.dendrogram.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) self.dendrogram.selectionChanged.connect(self._invalidate_output) self.dendrogram.selectionEdited.connect(self._selection_edited) self.labels = TextListWidget() self.labels.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred) self.labels.setAlignment(Qt.AlignLeft) self.labels.setMaximumWidth(200) scenelayout.addItem(self.top_axis, 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) scenelayout.addItem(self.dendrogram, 1, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) scenelayout.addItem(self.labels, 1, 1, alignment=Qt.AlignLeft | Qt.AlignVCenter) scenelayout.addItem(self.bottom_axis, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) self.view.viewport().installEventFilter(self) self._main_graphics.installEventFilter(self) self.top_axis.setZValue(self.dendrogram.zValue() + 10) self.bottom_axis.setZValue(self.dendrogram.zValue() + 10) self.cut_line = SliderLine(self.top_axis, orientation=Qt.Horizontal) self.cut_line.valueChanged.connect(self._dendrogram_slider_changed) self.dendrogram.geometryChanged.connect(self._dendrogram_geom_changed) self._set_cut_line_visible(self.selection_method == 1) self.__update_font_scale() @Inputs.distances def set_distances(self, matrix): if self.__pending_selection_restore is not None: selection_state = self.__pending_selection_restore else: # save the current selection to (possibly) restore later selection_state = self._save_selection() self.error() self.Error.clear() if matrix is not None: N, _ = matrix.shape if N < 2: self.error("Empty distance matrix") matrix = None if matrix is not None: if not np.all(np.isfinite(matrix)): self.Error.not_finite_distances() matrix = None self.matrix = matrix if matrix is not None: self._set_items(matrix.row_items, matrix.axis) else: self._set_items(None) self._invalidate_clustering() # Can now attempt to restore session state from a saved workflow. if self.root and selection_state is not None: self._restore_selection(selection_state) self.__pending_selection_restore = None self.commit.now() def _set_items(self, items, axis=1): self.closeContext() self.items = items model = self.label_cb.model() if len(model) == 3: self.annotation_if_names = self.annotation elif len(model) == 2: self.annotation_if_enumerate = self.annotation if isinstance(items, Orange.data.Table) and axis: model[:] = chain( self.basic_annotations, [model.Separator], items.domain.class_vars, items.domain.metas, [model.Separator] if (items.domain.class_vars or items.domain.metas) and next( filter_visible(items.domain.attributes), False) else [], filter_visible(items.domain.attributes)) if items.domain.class_vars: self.annotation = items.domain.class_vars[0] else: self.annotation = "Enumeration" self.openContext(items.domain) else: name_option = bool( items is not None and (not axis or isinstance(items, list) and all( isinstance(var, Orange.data.Variable) for var in items))) model[:] = self.basic_annotations + ["Name"] * name_option self.annotation = self.annotation_if_names if name_option \ else self.annotation_if_enumerate def _clear_plot(self): self.dendrogram.set_root(None) self.labels.setItems([]) def _set_displayed_root(self, root): self._clear_plot() self._displayed_root = root self.dendrogram.set_root(root) self._update_labels() self._main_graphics.resize( self._main_graphics.size().width(), self._main_graphics.sizeHint(Qt.PreferredSize).height()) self._main_graphics.layout().activate() def _update(self): self._clear_plot() distances = self.matrix if distances is not None: method = LINKAGE[self.linkage].lower() Z = dist_matrix_linkage(distances, linkage=method) tree = tree_from_linkage(Z) self.linkmatrix = Z self.root = tree self.top_axis.setRange(tree.value.height, 0.0) self.bottom_axis.setRange(tree.value.height, 0.0) if self.pruning: self._set_displayed_root(prune(tree, level=self.max_depth)) else: self._set_displayed_root(tree) else: self.linkmatrix = None self.root = None self._set_displayed_root(None) self._apply_selection() def _update_labels(self): labels = [] if self.root and self._displayed_root: indices = [leaf.value.index for leaf in leaves(self.root)] if self.annotation == "None": labels = [] elif self.annotation == "Enumeration": labels = [str(i + 1) for i in indices] elif self.annotation == "Name": attr = self.matrix.row_items.domain.attributes labels = [str(attr[i]) for i in indices] elif isinstance(self.annotation, Orange.data.Variable): col_data, _ = self.items.get_column_view(self.annotation) labels = [self.annotation.str_val(val) for val in col_data] labels = [labels[idx] for idx in indices] else: labels = [] if labels and self._displayed_root is not self.root: joined = leaves(self._displayed_root) labels = [ ", ".join(labels[leaf.value.first:leaf.value.last]) for leaf in joined ] self.labels.setItems(labels) self.labels.setMinimumWidth(1 if labels else -1) def _restore_selection(self, state): # type: (SelectionState) -> bool """ Restore the (manual) node selection state. Return True if successful; False otherwise. """ linkmatrix = self.linkmatrix if self.selection_method == 0 and self.root: selected, linksaved = state linkstruct = np.array(linksaved, dtype=float) selected = set(selected) # type: Set[Tuple[int]] if not selected: return False if linkmatrix.shape[0] != linkstruct.shape[0]: return False # check that the linkage matrix structure matches. Use isclose for # the height column to account for inexact floating point math # (e.g. summation order in different ?gemm implementations for # euclidean distances, ...) if np.any(linkstruct[:, :2] != linkmatrix[:, :2]) or \ not np.all(np.isclose(linkstruct[:, 2], linkstruct[:, 2])): return False selection = [] indices = np.array([n.value.index for n in leaves(self.root)], dtype=int) # mapping from ranges to display (pruned) nodes mapping = { node.value.range: node for node in postorder(self._displayed_root) } for node in postorder(self.root): # type: Tree r = tuple(indices[node.value.first:node.value.last]) if r in selected: if node.value.range not in mapping: # the node was pruned from display and cannot be # selected break selection.append(mapping[node.value.range]) selected.remove(r) if not selected: break # found all, nothing more to do if selection and selected: # Could not restore all selected nodes (only partial match) return False self._set_selected_nodes(selection) return True return False def _set_selected_nodes(self, selection): # type: (List[Tree]) -> None """ Set the nodes in `selection` to be the current selected nodes. The selection nodes must be subtrees of the current `_displayed_root`. """ self.dendrogram.selectionChanged.disconnect(self._invalidate_output) try: self.dendrogram.set_selected_clusters(selection) finally: self.dendrogram.selectionChanged.connect(self._invalidate_output) def _invalidate_clustering(self): self._update() self._update_labels() self._invalidate_output() def _invalidate_output(self): self.commit.deferred() def _invalidate_pruning(self): if self.root: selection = self.dendrogram.selected_nodes() ranges = [node.value.range for node in selection] if self.pruning: self._set_displayed_root(prune(self.root, level=self.max_depth)) else: self._set_displayed_root(self.root) selected = [ node for node in preorder(self._displayed_root) if node.value.range in ranges ] self.dendrogram.set_selected_clusters(selected) self._apply_selection() @gui.deferred def commit(self): items = getattr(self.matrix, "items", self.items) if not items: self.Outputs.selected_data.send(None) self.Outputs.annotated_data.send(None) return selection = self.dendrogram.selected_nodes() selection = sorted(selection, key=lambda c: c.value.first) indices = [leaf.value.index for leaf in leaves(self.root)] maps = [ indices[node.value.first:node.value.last] for node in selection ] selected_indices = list(chain(*maps)) unselected_indices = sorted( set(range(self.root.value.last)) - set(selected_indices)) if not selected_indices: self.Outputs.selected_data.send(None) annotated_data = create_annotated_table(items, []) \ if self.selection_method == 0 and self.matrix.axis else None self.Outputs.annotated_data.send(annotated_data) return selected_data = None if isinstance(items, Orange.data.Table) and self.matrix.axis == 1: # Select rows c = np.zeros(self.matrix.shape[0]) for i, indices in enumerate(maps): c[indices] = i c[unselected_indices] = len(maps) mask = c != len(maps) data, domain = items, items.domain attrs = domain.attributes classes = domain.class_vars metas = domain.metas var_name = get_unique_names(domain, "Cluster") values = [f"C{i + 1}" for i in range(len(maps))] clust_var = Orange.data.DiscreteVariable(var_name, values=values + ["Other"]) domain = Orange.data.Domain(attrs, classes, metas + (clust_var, )) data = items.transform(domain) with data.unlocked(data.metas): data.get_column_view(clust_var)[0][:] = c if selected_indices: selected_data = data[mask] clust_var = Orange.data.DiscreteVariable(var_name, values=values) selected_data.domain = Domain(attrs, classes, metas + (clust_var, )) annotated_data = create_annotated_table(data, selected_indices) elif isinstance(items, Orange.data.Table) and self.matrix.axis == 0: # Select columns attrs = [] for clust, indices in chain(enumerate(maps, start=1), [(0, unselected_indices)]): for i in indices: attr = items.domain[i].copy() attr.attributes["cluster"] = clust attrs.append(attr) domain = Orange.data.Domain( # len(unselected_indices) can be 0 attrs[:len(attrs) - len(unselected_indices)], items.domain.class_vars, items.domain.metas) selected_data = items.from_table(domain, items) domain = Orange.data.Domain(attrs, items.domain.class_vars, items.domain.metas) annotated_data = items.from_table(domain, items) self.Outputs.selected_data.send(selected_data) self.Outputs.annotated_data.send(annotated_data) def eventFilter(self, obj, event): if obj is self.view.viewport() and event.type() == QEvent.Resize: # NOTE: not using viewport.width(), due to 'transient' scroll bars # (macOS). Viewport covers the whole view, but QGraphicsView still # scrolls left, right with scroll bar extent (other # QAbstractScrollArea widgets behave as expected). w_frame = self.view.frameWidth() margin = self.view.viewportMargins() w_scroll = self.view.verticalScrollBar().width() width = (self.view.width() - w_frame * 2 - margin.left() - margin.right() - w_scroll) # layout with new width constraint self.__layout_main_graphics(width=width) elif obj is self._main_graphics and \ event.type() == QEvent.LayoutRequest: # layout preserving the width (vertical re layout) self.__layout_main_graphics() return super().eventFilter(obj, event) @Slot(QPointF) def _activate_cut_line(self, pos: QPointF): """Activate cut line selection an set cut value to `pos.x()`.""" self.selection_method = 1 self.cut_line.setValue(pos.x()) self._selection_method_changed() def onDeleteWidget(self): super().onDeleteWidget() self._clear_plot() self.dendrogram.clear() self.dendrogram.deleteLater() def _dendrogram_geom_changed(self): pos = self.dendrogram.pos_at_height(self.cutoff_height) geom = self.dendrogram.geometry() self._set_slider_value(pos.x(), geom.width()) self.cut_line.setLength(self.bottom_axis.geometry().bottom() - self.top_axis.geometry().top()) geom = self._main_graphics.geometry() assert geom.topLeft() == QPointF(0, 0) def adjustLeft(rect): rect = QRectF(rect) rect.setLeft(geom.left()) return rect margin = 3 self.scene.setSceneRect(geom) self.view.setSceneRect(geom) self.view.setHeaderSceneRect( adjustLeft(self.top_axis.geometry()).adjusted(0, 0, 0, margin)) self.view.setFooterSceneRect( adjustLeft(self.bottom_axis.geometry()).adjusted(0, -margin, 0, 0)) def _dendrogram_slider_changed(self, value): p = QPointF(value, 0) cl_height = self.dendrogram.height_at(p) self.set_cutoff_height(cl_height) def _set_slider_value(self, value, span): with blocked(self.cut_line): self.cut_line.setRange(0, span) self.cut_line.setValue(value) def set_cutoff_height(self, height): self.cutoff_height = height if self.root: self.cut_ratio = 100 * height / self.root.value.height self.select_max_height(height) def _set_cut_line_visible(self, visible): self.cut_line.setVisible(visible) def select_top_n(self, n): root = self._displayed_root if root: clusters = top_clusters(root, n) self.dendrogram.set_selected_clusters(clusters) def select_max_height(self, height): root = self._displayed_root if root: clusters = clusters_at_height(root, height) self.dendrogram.set_selected_clusters(clusters) def _selection_method_changed(self): self._set_cut_line_visible(self.selection_method == 1) if self.root: self._apply_selection() def _apply_selection(self): if not self.root: return if self.selection_method == 0: pass elif self.selection_method == 1: height = self.cut_ratio * self.root.value.height / 100 self.set_cutoff_height(height) pos = self.dendrogram.pos_at_height(height) self._set_slider_value(pos.x(), self.dendrogram.size().width()) elif self.selection_method == 2: self.select_top_n(self.top_n) def _selection_edited(self): # Selection was edited by clicking on a cluster in the # dendrogram view. self.selection_method = 0 self._selection_method_changed() self._invalidate_output() def _save_selection(self): # Save the current manual node selection state selection_state = None if self.selection_method == 0 and self.root: assert self.linkmatrix is not None linkmat = [(int(_0), int(_1), _2) for _0, _1, _2 in self.linkmatrix[:, :3].tolist()] nodes_ = self.dendrogram.selected_nodes() # match the display (pruned) nodes back (by ranges) mapping = {node.value.range: node for node in postorder(self.root)} nodes = [mapping[node.value.range] for node in nodes_] indices = [ tuple(node.value.index for node in leaves(node)) for node in nodes ] if nodes: selection_state = (indices, linkmat) return selection_state def save_state(self): # type: () -> Dict[str, Any] """ Save state for `set_restore_state` """ selection = self._save_selection() res = {"version": (0, 0, 0)} if selection is not None: res["selection_state"] = selection return res def set_restore_state(self, state): # type: (Dict[str, Any]) -> bool """ Restore session data from a saved state. Parameters ---------- state : Dict[str, Any] NOTE ---- This is method called while the instance (self) is being constructed, even before its `__init__` is called. Consider `self` to be only a `QObject` at this stage. """ if "selection_state" in state: selection = state["selection_state"] self.__pending_selection_restore = selection return True def __zoom_in(self): def clip(minval, maxval, val): return min(max(val, minval), maxval) self.zoom_factor = clip(self.zoom_slider.minimum(), self.zoom_slider.maximum(), self.zoom_factor + 1) self.__update_font_scale() def __zoom_out(self): def clip(minval, maxval, val): return min(max(val, minval), maxval) self.zoom_factor = clip(self.zoom_slider.minimum(), self.zoom_slider.maximum(), self.zoom_factor - 1) self.__update_font_scale() def __zoom_reset(self): self.zoom_factor = 0 self.__update_font_scale() def __layout_main_graphics(self, width=-1): if width < 0: # Preserve current width. width = self._main_graphics.size().width() preferred = self._main_graphics.effectiveSizeHint(Qt.PreferredSize, constraint=QSizeF( width, -1)) self._main_graphics.resize(QSizeF(width, preferred.height())) mw = self._main_graphics.minimumWidth() + 4 self.view.setMinimumWidth(mw + self.view.verticalScrollBar().width()) def __update_font_scale(self): font = self.scene.font() factor = (1.25**self.zoom_factor) font = qfont_scaled(font, factor) self._main_graphics.setFont(font) def send_report(self): annot = self.label_cb.currentText() if isinstance(self.annotation, str): annot = annot.lower() if self.selection_method == 0: sel = "manual" elif self.selection_method == 1: sel = "at {:.1f} of height".format(self.cut_ratio) else: sel = "top {} clusters".format(self.top_n) self.report_items(( ("Linkage", LINKAGE[self.linkage].lower()), ("Annotation", annot), ("Prunning", self.pruning != 0 and "{} levels".format(self.max_depth)), ("Selection", sel), )) self.report_plot()
def create_view(self): view = StickyGraphicsView() scene = QGraphicsScene(view) view.setScene(scene) return view
def __init__(self): super().__init__() #: The input data self.data = None # type: Optional[Orange.data.Table] #: Distance matrix computed from data self._matrix = None # type: Optional[Orange.misc.DistMatrix] #: An bool mask (size == len(data)) indicating missing group/cluster #: assignments self._mask = None # type: Optional[np.ndarray] #: An array of cluster/group labels for instances with valid group #: assignment self._labels = None # type: Optional[np.ndarray] #: An array of silhouette scores for instances with valid group #: assignment self._silhouette = None # type: Optional[np.ndarray] self._silplot = None # type: Optional[SilhouettePlot] gui.comboBox( self.controlArea, self, "distance_idx", box="Distance", items=[name for name, _ in OWSilhouettePlot.Distances], orientation=Qt.Horizontal, callback=self._invalidate_distances) box = gui.vBox(self.controlArea, "Cluster Label") self.cluster_var_cb = gui.comboBox( box, self, "cluster_var_idx", contentsLength=14, addSpace=4, callback=self._invalidate_scores ) gui.checkBox( box, self, "group_by_cluster", "Group by cluster", callback=self._replot) self.cluster_var_model = itemmodels.VariableListModel(parent=self) self.cluster_var_cb.setModel(self.cluster_var_model) box = gui.vBox(self.controlArea, "Bars") gui.widgetLabel(box, "Bar width:") gui.hSlider( box, self, "bar_size", minValue=1, maxValue=10, step=1, callback=self._update_bar_size, addSpace=6) gui.widgetLabel(box, "Annotations:") self.annotation_cb = gui.comboBox( box, self, "annotation_var_idx", contentsLength=14, callback=self._update_annotations) self.annotation_var_model = itemmodels.VariableListModel(parent=self) self.annotation_var_model[:] = ["None"] self.annotation_cb.setModel(self.annotation_var_model) ibox = gui.indentedBox(box, 5) self.ann_hidden_warning = warning = gui.widgetLabel( ibox, "(increase the width to show)") ibox.setFixedWidth(ibox.sizeHint().width()) warning.setVisible(False) gui.rubber(self.controlArea) gui.separator(self.buttonsArea) box = gui.vBox(self.buttonsArea, "Output") # Thunk the call to commit to call conditional commit gui.checkBox(box, self, "add_scores", "Add silhouette scores", callback=lambda: self.commit()) gui.auto_send(box, self, "auto_commit", box=False) # Ensure that the controlArea is not narrower than buttonsArea self.controlArea.layout().addWidget(self.buttonsArea) self.scene = QGraphicsScene() self.view = StickyGraphicsView(self.scene) self.view.setRenderHint(QPainter.Antialiasing, True) self.view.setAlignment(Qt.AlignTop | Qt.AlignLeft) self.mainArea.layout().addWidget(self.view) self.settingsAboutToBePacked.connect(self.pack_settings)