diff --git a/clusterview2.ui b/clusterview2.ui index 4d5c2f7..1f1316a 100644 --- a/clusterview2.ui +++ b/clusterview2.ui @@ -103,12 +103,12 @@ - Centroids + Clusters - + 0 @@ -130,42 +130,32 @@ - + - true + false - Choose Centroids + Unweighted Clustering - + false - Unweighted Clustering + Weighted Clustering - + Reset - - - - false - - - Weighted Clustering - - - diff --git a/clusterview2/debug.py b/clusterview2/debug.py new file mode 100644 index 0000000..0ad0c5e --- /dev/null +++ b/clusterview2/debug.py @@ -0,0 +1,9 @@ +def debug_trace(): + """ + A wrapper for pdb that works with PyQt5. + """ + from PyQt5.QtCore import pyqtRemoveInputHook + + from pdb import set_trace + pyqtRemoveInputHook() + set_trace() diff --git a/clusterview2/mode.py b/clusterview2/mode.py index e54cbe9..f7699d3 100644 --- a/clusterview2/mode.py +++ b/clusterview2/mode.py @@ -13,6 +13,5 @@ class Mode(Enum): MOVE = 3 DELETE = 4 LOADED = 5 - CHOOSE_CENTROIDS = 6 - UNWEIGHTED_CLUSTERING = 7 - WEIGHTED_CLUSTERING = 8 + UNWEIGHTED_CLUSTERING = 6 + WEIGHTED_CLUSTERING = 7 diff --git a/clusterview2/point_manager.py b/clusterview2/point_manager.py index 135c04b..84931c0 100644 --- a/clusterview2/point_manager.py +++ b/clusterview2/point_manager.py @@ -11,7 +11,7 @@ class PointManager(): """ point_set = None - centroids = [] + clusters = [] @staticmethod def load(location): diff --git a/clusterview2/points.py b/clusterview2/points.py index bec91fd..5c75c38 100644 --- a/clusterview2/points.py +++ b/clusterview2/points.py @@ -14,7 +14,7 @@ class Point(BasePoint): """ def __init__(self, x, y, color, point_size, - viewport_width, viewport_height): + viewport_width, viewport_height, weight=1.0): """ Initializes a new point with a point_size bounding box, viewport awareness, and a color. @@ -35,8 +35,14 @@ class Point(BasePoint): "type Color.") self._point_size = point_size + + # Unfortunately, it appears decorated property methods are not + # inheirited and instead of redoing everything we will just repeat + # the properties here. self._x = x self._y = y + self._cluster = None + self._weight = weight self._color = color @@ -60,6 +66,18 @@ class Point(BasePoint): return self._y @property + def weight(self): + return self._weight + + @property + def cluster(self): + return self._cluster + + @cluster.setter + def cluster(self, cluster): + self._cluster = cluster + + @property def point_size(self): return self._point_size diff --git a/clusterview2/ui/mode_handlers.py b/clusterview2/ui/mode_handlers.py index 857f068..1ee1dd1 100644 --- a/clusterview2/ui/mode_handlers.py +++ b/clusterview2/ui/mode_handlers.py @@ -3,12 +3,14 @@ import random from PyQt5.QtCore import QEvent, Qt from PyQt5.QtGui import QCursor +from kmeans.algorithms import k_means + from clusterview2.colors import Color from clusterview2.exceptions import ExceededWindowBoundsError from clusterview2.mode import Mode -from .opengl_widget import (set_drawing_event, set_move_bb_top_left, - set_move_bb_bottom_right, reset_move_bbs, - viewport_height, viewport_width) +from clusterview2.ui.opengl_widget import (set_drawing_event, set_move_bb_top_left, + set_move_bb_bottom_right, reset_move_bbs, + viewport_height, viewport_width) from clusterview2.point_manager import PointManager @@ -293,64 +295,9 @@ def _handle_info_updates(ctx, event): ctx.mouse_position_label.setText(f"{event.x(), event.y()}") -def _handle_choose_centroids(ctx, event): - """ - Similar to move in terms of selecting points, however this - function assigns a random color up to the maximum number - of centroids, and after the maximum number has been selected it will - enable the group button. - """ - global _centroid_count +def reset_colors(): global _remaining_colors - _handle_info_updates(ctx, event) - - if _centroid_count == ctx.number_of_centroids.value(): - # We have specified the number of centroids required - return - - if (event.button() == Qt.LeftButton and - event.type() == QEvent.MouseButtonPress): - - point = None - - for test_point in PointManager.point_set.points: - if test_point.hit(event.x(), event.y()): - point = test_point - - if point is None: - # No point was found on the click, do nothing - return - - if point in PointManager.centroids: - # Centroids must be unique - return - - _centroid_count += 1 - - color = random.choice(_remaining_colors) - _remaining_colors.remove(color) - - point.color = color - - # Recolor the point and restash the point in centroids - PointManager.centroids.append(point) - - if _centroid_count == ctx.number_of_centroids.value(): - # Prevent the user from changing the centroids - ctx.number_of_centroids.setEnabled(False) - ctx.choose_centroids_button.setEnabled(False) - ctx.unweighted_clustering_button.setEnabled(True) - ctx.weighted_clustering_button.setEnabled(True) - - ctx.opengl_widget.update() - - -def reset_centroid_count_and_colors(): - global _centroid_count - global _remaining_colors - - _centroid_count = 0 _remaining_colors = [c for c in Color if c not in [Color.BLUE, Color.GREY]] for point in PointManager.point_set.points: @@ -389,6 +336,27 @@ def generate_random_points(point_count, x_bound, y_bound): PointManager.point_set.add_point(point[0], point[1], Color.GREY) +def _handle_clustering(ctx, _): + points = list(PointManager.point_set.points) + + if not ctx.clustering_solved: + clusters = k_means(points, ctx.number_of_clusters.value(), 0.001) + + # We can leverage the paint function by first assigning every cluster + # a color (for completeness) and then assigning every point in that + # cluster the cluster's color. + for i, cluster in enumerate(clusters): + cluster.color = _remaining_colors[i] + + for point in cluster.points: + point.color = cluster.color + + PointManager.clusters = clusters + + ctx.opengl_widget.update() + ctx.clustering_solved = True + + # Simple dispatcher to make it easy to dispatch the right mode # function when the OpenGL window is acted on. MODE_HANDLER_MAP = { @@ -398,5 +366,5 @@ MODE_HANDLER_MAP = { Mode.EDIT: _handle_edit_point, Mode.MOVE: _handle_move_points, Mode.DELETE: _handle_delete_point, - Mode.CHOOSE_CENTROIDS: _handle_choose_centroids + Mode.UNWEIGHTED_CLUSTERING: _handle_clustering } diff --git a/clusterview2/ui/opengl_widget.py b/clusterview2/ui/opengl_widget.py index d52659e..a53da3d 100644 --- a/clusterview2/ui/opengl_widget.py +++ b/clusterview2/ui/opengl_widget.py @@ -183,7 +183,6 @@ def paint_gl(): if (__current_context.mode is Mode.ADD or __current_context.mode is Mode.DELETE or __current_context.mode is Mode.LOADED or - __current_context.mode is Mode.CHOOSE_CENTROIDS or __current_context.mode is Mode.UNWEIGHTED_CLUSTERING or __current_context.mode is Mode.WEIGHTED_CLUSTERING): diff --git a/clusterview2_ui.py b/clusterview2_ui.py index a14fa48..5c3cb56 100644 --- a/clusterview2_ui.py +++ b/clusterview2_ui.py @@ -64,31 +64,27 @@ class Ui_MainWindow(object): self.label_2 = QtWidgets.QLabel(self.groupBox_3) self.label_2.setObjectName("label_2") self.formLayout.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.label_2) - self.number_of_centroids = QtWidgets.QSpinBox(self.groupBox_3) + self.number_of_clusters = QtWidgets.QSpinBox(self.groupBox_3) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.number_of_centroids.sizePolicy().hasHeightForWidth()) - self.number_of_centroids.setSizePolicy(sizePolicy) - self.number_of_centroids.setMinimumSize(QtCore.QSize(50, 26)) - self.number_of_centroids.setMaximumSize(QtCore.QSize(50, 16777215)) - self.number_of_centroids.setObjectName("number_of_centroids") - self.formLayout.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.number_of_centroids) - self.choose_centroids_button = QtWidgets.QPushButton(self.groupBox_3) - self.choose_centroids_button.setEnabled(True) - self.choose_centroids_button.setObjectName("choose_centroids_button") - self.formLayout.setWidget(3, QtWidgets.QFormLayout.LabelRole, self.choose_centroids_button) + sizePolicy.setHeightForWidth(self.number_of_clusters.sizePolicy().hasHeightForWidth()) + self.number_of_clusters.setSizePolicy(sizePolicy) + self.number_of_clusters.setMinimumSize(QtCore.QSize(50, 26)) + self.number_of_clusters.setMaximumSize(QtCore.QSize(50, 16777215)) + self.number_of_clusters.setObjectName("number_of_clusters") + self.formLayout.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.number_of_clusters) self.unweighted_clustering_button = QtWidgets.QPushButton(self.groupBox_3) self.unweighted_clustering_button.setEnabled(False) self.unweighted_clustering_button.setObjectName("unweighted_clustering_button") - self.formLayout.setWidget(4, QtWidgets.QFormLayout.LabelRole, self.unweighted_clustering_button) - self.reset_button = QtWidgets.QPushButton(self.groupBox_3) - self.reset_button.setObjectName("reset_button") - self.formLayout.setWidget(6, QtWidgets.QFormLayout.LabelRole, self.reset_button) + self.formLayout.setWidget(3, QtWidgets.QFormLayout.LabelRole, self.unweighted_clustering_button) self.weighted_clustering_button = QtWidgets.QPushButton(self.groupBox_3) self.weighted_clustering_button.setEnabled(False) self.weighted_clustering_button.setObjectName("weighted_clustering_button") - self.formLayout.setWidget(5, QtWidgets.QFormLayout.LabelRole, self.weighted_clustering_button) + self.formLayout.setWidget(4, QtWidgets.QFormLayout.LabelRole, self.weighted_clustering_button) + self.reset_button = QtWidgets.QPushButton(self.groupBox_3) + self.reset_button.setObjectName("reset_button") + self.formLayout.setWidget(5, QtWidgets.QFormLayout.LabelRole, self.reset_button) self.verticalLayout.addWidget(self.groupBox_3) spacerItem = QtWidgets.QSpacerItem(20, 20, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) self.verticalLayout.addItem(spacerItem) @@ -170,11 +166,10 @@ class Ui_MainWindow(object): MainWindow.setWindowTitle(_translate("MainWindow", "ClusterView2")) self.groupBox.setTitle(_translate("MainWindow", "Point List")) self.groupBox_3.setTitle(_translate("MainWindow", "Solver")) - self.label_2.setText(_translate("MainWindow", "Centroids")) - self.choose_centroids_button.setText(_translate("MainWindow", "Choose Centroids")) + self.label_2.setText(_translate("MainWindow", "Clusters")) self.unweighted_clustering_button.setText(_translate("MainWindow", "Unweighted Clustering")) - self.reset_button.setText(_translate("MainWindow", "Reset")) self.weighted_clustering_button.setText(_translate("MainWindow", "Weighted Clustering")) + self.reset_button.setText(_translate("MainWindow", "Reset")) self.groupBox_2.setTitle(_translate("MainWindow", "Canvas Information")) self.label.setText(_translate("MainWindow", "Mouse Position:")) self.menu_file.setTitle(_translate("MainWindow", "File")) diff --git a/main_window.py b/main_window.py index 4860a64..e4ae60e 100644 --- a/main_window.py +++ b/main_window.py @@ -10,7 +10,7 @@ from clusterview2.mode import Mode from clusterview2.ui.mode_handlers import (MODE_HANDLER_MAP, ogl_keypress_handler, refresh_point_list, - reset_centroid_count_and_colors, + reset_colors, generate_random_points) from clusterview2.ui.opengl_widget import (clear_selection, initialize_gl, mouse_leave, paint_gl, resize_gl, @@ -50,12 +50,12 @@ class MainWindow(QMainWindow, Ui_MainWindow): self._viewport_width, self._viewport_height) - # Spin box should only allow the number of centroids to be no + # Spin box should only allow the number of clusters to be no # greater than the number of supported colors minus 2 to exclude # the color for selection (Color.BLUE) and the default color for points # (Color.GREY). - self.number_of_centroids.setMinimum(0) - self.number_of_centroids.setMaximum(Color.count() - 2) + self.number_of_clusters.setMinimum(0) + self.number_of_clusters.setMaximum(Color.count() - 2) # We only need to set the context in our OpenGL state machine # wrapper once here since the window is fixed size. @@ -83,9 +83,8 @@ class MainWindow(QMainWindow, Ui_MainWindow): self.point_list_widget.itemClicked.connect(partial(item_click_handler, self)) - self.choose_centroids_button.clicked.connect(self._choose_centroids) - self.unweighted_clustering_button.clicked.connect(self._unweighted_clustering) + self.number_of_clusters.valueChanged.connect(self._clustering_enabled) self.reset_button.clicked.connect(self._reset) @@ -123,6 +122,9 @@ class MainWindow(QMainWindow, Ui_MainWindow): self.opengl_widget.mouseMoveEvent = self._ogl_click_dispatcher self.opengl_widget.mouseReleaseEvent = self._ogl_click_dispatcher + # Clustering flag so it does not continue to run + self.clustering_solved = False + # ----------------------------------------------------------------- # Mode changers - these will be used to signal the action in the # OpenGL Widget. @@ -164,33 +166,22 @@ class MainWindow(QMainWindow, Ui_MainWindow): clear_selection() self.opengl_widget.update() - def _choose_centroids(self): - self._mode = Mode.CHOOSE_CENTROIDS - self.opengl_widget.setCursor(QCursor(Qt.CursorShape.CrossCursor)) - self.status_bar.showMessage('CHOOSE CENTROIDS') - - clear_selection() - self.opengl_widget.update() - def _unweighted_clustering(self): + clear_selection() self._mode = Mode.UNWEIGHTED_CLUSTERING - self.opengl_widget.setCursor(QCursor(Qt.CursorShape.ArrowCursor)) self.status_bar.showMessage('UNWEIGHTED CLUSTERING') - clear_selection() - # unweighted_clustering(self) - self._off_mode() self.opengl_widget.update() def _reset(self): self._off_mode() - self.number_of_centroids.setEnabled(True) - self.number_of_centroids.setValue(0) - self.choose_centroids_button.setEnabled(True) + self.number_of_clusters.setEnabled(True) + self.number_of_clusters.setValue(0) self.unweighted_clustering_button.setEnabled(False) self.weighted_clustering_button.setEnabled(False) - PointManager.centroids = [] - reset_centroid_count_and_colors() + self.clustering_solved = False + PointManager.clusters = [] + reset_colors() def _generate_random_points(self): value, ok = QInputDialog.getInt(self, 'Number of Points', @@ -208,6 +199,10 @@ class MainWindow(QMainWindow, Ui_MainWindow): refresh_point_list(self) + def _clustering_enabled(self): + self.unweighted_clustering_button.setEnabled(self.number_of_clusters.value() > 0) + self.weighted_clustering_button.setEnabled(self.number_of_clusters.value() > 0) + @property def mode(self): """"