From 48e70df2bbaca15be8ea5a2314a01b86a5427fb4 Mon Sep 17 00:00:00 2001 From: Taylor Bockman Date: Tue, 15 Oct 2019 20:25:03 -0700 Subject: [PATCH 1/8] Porting over necessary clusterview components. --- .gitignore | 1 + README.md | 13 +- TODO.org | 14 ++ clusterview2.py | 18 ++ clusterview2.ui | 380 +++++++++++++++++++++++++++++++++ clusterview2/__init__.py | 0 clusterview2/colors.py | 27 +++ clusterview2/exceptions.py | 53 +++++ clusterview2/mode.py | 17 ++ clusterview2/point_manager.py | 59 +++++ clusterview2/ui/__init__.py | 0 clusterview2/ui/mode_handlers.py | 402 +++++++++++++++++++++++++++++++++++ clusterview2/ui/opengl_widget.py | 389 +++++++++++++++++++++++++++++++++ clusterview2/ui/point_list_widget.py | 53 +++++ clusterview2_ui.py | 200 +++++++++++++++++ main_window.py | 259 ++++++++++++++++++++++ requirements-dev.txt | 8 + requirements.txt | 6 + 18 files changed, 1898 insertions(+), 1 deletion(-) create mode 100644 TODO.org create mode 100644 clusterview2.py create mode 100644 clusterview2.ui create mode 100644 clusterview2/__init__.py create mode 100644 clusterview2/colors.py create mode 100644 clusterview2/exceptions.py create mode 100644 clusterview2/mode.py create mode 100644 clusterview2/point_manager.py create mode 100644 clusterview2/ui/__init__.py create mode 100644 clusterview2/ui/mode_handlers.py create mode 100644 clusterview2/ui/opengl_widget.py create mode 100644 clusterview2/ui/point_list_widget.py create mode 100644 clusterview2_ui.py create mode 100644 main_window.py create mode 100644 requirements-dev.txt create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore index 6a18ad4..7b93c1e 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,4 @@ ENV/ # Rope project settings .ropeproject +.mypy_cache diff --git a/README.md b/README.md index 2dac13a..3bbf649 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,14 @@ # clusterview2 -The sequel to clusterview. Built around the point and cluster structure of the kmeans project, aims to improve upon the design and structural weakness of clusterview and add many interesting interactive ways to explore kmeans. \ No newline at end of file +The sequel to clusterview. Built around the point and cluster structure of the kmeans project, +aims to improve upon the design and structural weakness of clusterview and add many interesting interactive ways +to explore k-means. + +## Usage + +TODO + +## Development + +Make sure to install the development requirements using `pip install -r requirements-dev.txt`. This will install +all main requirements as well as useful testing and linting tools. diff --git a/TODO.org b/TODO.org new file mode 100644 index 0000000..0580c9b --- /dev/null +++ b/TODO.org @@ -0,0 +1,14 @@ +TODO: + + * Port over and improve clusterview and it's opengl stuff. It should use k-means point and cluster, as well as + the math library from kmeans (add a test to dist). Additionally, the point and cluster of the clusterview should + inherit from k-means point and cluster so that it can use the algorithm correctly. All other aspects of point and + cluster in the clusterview program can be kept. + * Extract and improve the overall structure of clusterview so that globals are not used unless absolutely necessary. + * Turn kmeans into a python package and import it here. + * Remove old clusterview buttons, keep the status window side and saving feature. Add new buttons for weighted and + unweighted clustering. + * Add a property to the point called weight, which is set when you click edit point. It will give a popup that + allows you to specify a point weight. + * Use kmeans to do the stuff. + * Weighted cluster mean is rendered as a hollow circle and unweighted k-means mean is a x. diff --git a/clusterview2.py b/clusterview2.py new file mode 100644 index 0000000..25f218f --- /dev/null +++ b/clusterview2.py @@ -0,0 +1,18 @@ +import sys + +from PyQt5.QtWidgets import QApplication + +from main_window import MainWindow + + +def main(): + app = QApplication(sys.argv) + + window = MainWindow() + window.show() + + sys.exit(app.exec_()) + + +if __name__ == '__main__': + main() diff --git a/clusterview2.ui b/clusterview2.ui new file mode 100644 index 0000000..51d9afb --- /dev/null +++ b/clusterview2.ui @@ -0,0 +1,380 @@ + + + MainWindow + + + + 0 + 0 + 1280 + 720 + + + + + 0 + 0 + + + + + 1280 + 720 + + + + + 1280 + 720 + + + + ClusterView + + + + + + + + 0 + 0 + + + + + 900 + 16777215 + + + + + + + + + + + 0 + 0 + + + + + 100 + 0 + + + + + 200 + 200 + + + + Point List + + + + + + + 0 + 0 + + + + + 100 + 0 + + + + + + + + + + + Solver + + + + + + Centroids + + + + + + + + 0 + 0 + + + + + 50 + 26 + + + + + 50 + 16777215 + + + + + + + + true + + + Choose Centroids + + + + + + + false + + + Solve + + + + + + + false + + + Group + + + + + + + Reset + + + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 20 + + + + + + + + Canvas Information + + + + + + + 0 + 0 + + + + + 100 + 0 + + + + + + + + + + + Qt::Vertical + + + + 20 + 20 + + + + + + + + Mouse Position: + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 20 + 20 + + + + + + + + + + + + + + + 0 + 0 + 1280 + 28 + + + + true + + + + File + + + + + + + + + Help + + + + + + + + + toolBar + + + false + + + LeftToolBarArea + + + false + + + + + + + + + + Add Points + + + Enables point adding mode. + + + Ctrl+A + + + + + Edit Points + + + Enables point editing mode. + + + Ctrl+E + + + + + Delete Points + + + Enables point deletion mode. + + + Ctrl+D + + + + + Solve + + + Opens the solve dialog to choose a solving solution. + + + Ctrl+S + + + + + Move Points + + + Enables the movement of a selection of points. + + + + + Save Point Configuration + + + + + Load Point Configuration + + + + + Exit + + + + + Generate Random Points + + + + + + diff --git a/clusterview2/__init__.py b/clusterview2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/clusterview2/colors.py b/clusterview2/colors.py new file mode 100644 index 0000000..32f14c8 --- /dev/null +++ b/clusterview2/colors.py @@ -0,0 +1,27 @@ +from enum import Enum + + +class Color(str, Enum): + BLUE = 'BLUE' + BLACK = 'BLACK' + GREY = 'GREY' + RED = 'RED' + ORANGE = 'ORANGE' + PURPLE = 'PURPLE' + + @classmethod + def count(cls): + return len(cls.__members__) + + +# A simple map from Color -> RGBA 4-Tuple +# Note: The color values in the tuple are not RGB, but +# rather OpenGL percentage values for RGB. +COLOR_TO_RGBA = { + Color.GREY: (0.827, 0.827, 0.826, 0.0), + Color.BLUE: (0.118, 0.565, 1.0, 0.0), + Color.BLACK: (0.0, 0.0, 0.0, 0.0), + Color.RED: (1.0, 0.0, 0.0, 0.0), + Color.ORANGE: (0.98, 0.625, 0.12, 0.0), + Color.PURPLE: (0.60, 0.40, 0.70, 0.0) +} diff --git a/clusterview2/exceptions.py b/clusterview2/exceptions.py new file mode 100644 index 0000000..5710393 --- /dev/null +++ b/clusterview2/exceptions.py @@ -0,0 +1,53 @@ +from PyQt5.QtWidgets import QErrorMessage + +from clusterview2.mode import Mode + +class ExceededWindowBoundsError(Exception): + pass + +class InvalidStateError(Exception): + pass + +class InvalidModeError(Exception): + """ + An exception to specify an invalid mode has been provided. + """ + + def __init__(self, mode): + """ + Initializes the InvalidMode exception with a + mode. + """ + + if not isinstance(mode, Mode): + raise ValueError("Mode argument to InvalidMode must be of " + + " type mode") + + # Mode cases for invalid mode + if mode == Mode.OFF: + super().__init__("You must select a mode before continuing.") + +def handle_exceptions(func): + """ + A decorator designed to make exceptions thrown + from a function easier to handle. + + The result will be that all exceptions coming from + the decorated function will be caught and displayed + as a error message box. + + Usage: + + @handle_exceptions + def my_qt_func(): + raises SomeException + """ + def wrapped(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + error_dialog = QErrorMessage() + error_dialog.showMessage(str(e)) + error_dialog.exec_() + + return wrapped diff --git a/clusterview2/mode.py b/clusterview2/mode.py new file mode 100644 index 0000000..c4b1ff4 --- /dev/null +++ b/clusterview2/mode.py @@ -0,0 +1,17 @@ +from enum import Enum + + +class Mode(Enum): + """ + Class to make it easier to figure out what mode + we are operating in when the OpenGL window is + clicked. + """ + OFF = 0 + ADD = 1 + EDIT = 2 + MOVE = 3 + DELETE = 4 + LOADED = 5 + CHOOSE_CENTROIDS = 6 # TODO: Can replace with choose weighted or something + GROUP = 7 diff --git a/clusterview2/point_manager.py b/clusterview2/point_manager.py new file mode 100644 index 0000000..135c04b --- /dev/null +++ b/clusterview2/point_manager.py @@ -0,0 +1,59 @@ +import json + +from clusterview2.colors import Color +from clusterview2.points import PointSet + + +class PointManager(): + """ + A state class that represents the absolute state of the + world in regards to points. + """ + + point_set = None + centroids = [] + + @staticmethod + def load(location): + """ + Loads the JSON file from the location and populates point_set + with it's contents. + + @param location The location of the JSON file. + """ + with open(location) as json_file: + data = json.load(json_file) + + PointManager.point_set = PointSet(data['point_size'], + data['viewport_width'], + data['viewport_height']) + + for point in data['points']: + # We will need to cast the string representation of color + # back into a Color enum. + PointManager.point_set.add_point(point['x'], point['y'], + Color(point['color'])) + + @staticmethod + def save(location): + """ + Persists the point_set as a JSON file at location. + + @param location The persistence location. + """ + + data = {} + data['point_size'] = PointManager.point_set.point_size + data['viewport_width'] = PointManager.point_set.viewport_width + data['viewport_height'] = PointManager.point_set.viewport_height + data['points'] = [] + + for p in PointManager.point_set.points: + data['points'].append({ + 'x': p.x, + 'y': p.y, + 'color': p.color + }) + + with open(location, 'w') as out_file: + json.dump(data, out_file) diff --git a/clusterview2/ui/__init__.py b/clusterview2/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/clusterview2/ui/mode_handlers.py b/clusterview2/ui/mode_handlers.py new file mode 100644 index 0000000..0980e6e --- /dev/null +++ b/clusterview2/ui/mode_handlers.py @@ -0,0 +1,402 @@ +import random + +from PyQt5.QtCore import QEvent, Qt +from PyQt5.QtGui import QCursor + +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.point_manager import PointManager + + +class __ClickFlag: + + # This is the first stage. On mouse release it goes to + # SELECTION_MOVE. + NONE = 0 + + # We are now in selection box mode. + SELECTION_BOX = 1 + + # Second stage - we have selected a number of points + # and now we are going to track the left mouse button + # to translate those points. After a left click + # this moves to SELECTED_MOVED. + SELECTION_MOVE = 2 + + # Any subsequent click in this mode will send it back + # to NONE - we are done. + SELECTED_MOVED = 3 + + +# GLOBALS + +# Canvas pixel border - empirical, not sure where this is stored officially +__CANVAS_BORDER = 1 + +# Module level flag for left click events (used to detect a left +# click hold drag) +__left_click_flag = __ClickFlag.NONE + +# Variable to track the mouse state during selection movement +__last_mouse_pos = None + +# Used to implement mouse dragging when clicked +__left_click_down = False + +# TODO: WHEN THE GROUPING ENDS AND THE USER CANCELS THE CENTROID COUNT +# SHOULD BE ZEROED AND REMAINING COLORS SHOULD BE REPOPULATED. +# Count of centroids for comparison with the spin widget +__centroid_count = 0 +__remaining_colors = [c for c in Color if c not in [Color.BLUE, Color.GREY]] + + +def refresh_point_list(ctx): + """ + Refreshes the point list display. + + @param ctx A handle to the window context. + """ + # In order to make some guarantees and avoid duplicate + # data we will clear the point list widget and re-populate + # it using the current __point_set. + ctx.point_list_widget.clear() + + for p in PointManager.point_set.points: + ctx.point_list_widget.addItem("({}, {})".format(p.x, p.y)) + + ctx.point_list_widget.update() + + +def __handle_add_point(ctx, event): + """ + Event handler for the add point mode. + + Sets the drawing mode for the OpenGL Widget using + `set_drawing_mode`, converts a point to our point + representation, and adds it to the list. + + @param ctx A context handle to the main window. + @param event The click event. + """ + + # Update information as needed + __handle_info_updates(ctx, event) + + if (event.button() == Qt.LeftButton and + event.type() == QEvent.MouseButtonPress): + + # At this point we can be sure resize_gl has been called + # at least once, so set the viewport properties of the + # point set so it knows the canvas bounds. + PointManager.point_set.viewport_width = viewport_width() + PointManager.point_set.viewport_height = viewport_height() + + # Clear any existing selections + PointManager.point_set.clear_selection() + + try: + # No attribute at the moment, default point color is Color.GREY. + PointManager.point_set.add_point(event.x(), event.y(), Color.GREY) + except ExceededWindowBoundsError: + # The user tried to place a point whos edges would be + # on the outside of the window. We will just ignore it. + return + + refresh_point_list(ctx) + + set_drawing_event(event) + + ctx.opengl_widget.update() + ctx.point_list_widget.update() + + +def __handle_edit_point(ctx, event): + # TODO: This function and delete definitely need to make sure they are + # on a point we have. + # + # Since points are unique consider a hashmap of points to make O(1) + # lookups for addition and deletion. This list can be maintained here + # in this module. It should be a dictionary - from point to + # attributes in the case of algorithms that require points to have + # weights or something. + # + # Should move the associated point in the list to the new location if + # applicable. + + __handle_info_updates(ctx, event) + PointManager.point_set.clear_selection() + + # Store old x, y from event + set_drawing_event(event) + ctx.update() + # after this remove the point from the list + + +def ogl_keypress_handler(ctx, event): + """ + A keypress handler attached to the OpenGL widget. + + It primarily exists to allow the user to cancel selection. + + Also allows users to escape from modes. + + @param ctx A handle to the window context. + @param event The event associated with this handler. + """ + global __left_click_flag + global __last_mouse_pos + + if event.key() == Qt.Key_Escape: + if ctx.mode is Mode.MOVE: + if __left_click_flag is not __ClickFlag.NONE: + + __last_mouse_pos = None + + __left_click_flag = __ClickFlag.NONE + PointManager.point_set.clear_selection() + reset_move_bbs() + refresh_point_list(ctx) + + elif ctx.mode is not Mode.OFF: + ctx.mode = Mode.OFF + + # Also change the mouse back to normal + ctx.opengl_widget.setCursor(QCursor(Qt.CursorShape.ArrowCursor)) + ctx.status_bar.showMessage("") + + ctx.opengl_widget.update() + + +def __handle_move_points(ctx, event): + """ + A relatively complicated state machine that handles the process of + selection, clicking, and dragging. + + @param ctx The context to the window. + @param event The event. + """ + + global __left_click_flag + global __left_mouse_down + global __last_mouse_pos + + set_drawing_event(event) + + __handle_info_updates(ctx, event) + + # If we release the mouse, we want to quickly alert drag mode. + if (event.button() == Qt.LeftButton and + event.type() == QEvent.MouseButtonRelease): + + __left_mouse_down = False + + # This if statement block is used to set the bounding box for + # drawing and call the selection procedure. + if (event.button() == Qt.LeftButton and + event.type() == QEvent.MouseButtonPress): + + __left_mouse_down = True + + if __left_click_flag is __ClickFlag.NONE: + __left_click_flag = __ClickFlag.SELECTION_BOX + + set_move_bb_top_left(event.x(), event.y()) + + elif (__left_click_flag is __ClickFlag.SELECTION_BOX + and __left_mouse_down): + # We are now in the click-and-hold to signal move + # tracking and translation + __left_click_flag = __ClickFlag.SELECTION_MOVE + __last_mouse_pos = (event.x(), event.y()) + + # Post-selection handlers + if (__left_click_flag is __ClickFlag.SELECTION_BOX + and event.type() == QEvent.MouseMove): + + set_move_bb_bottom_right(event.x(), event.y()) + + elif (__left_click_flag is __ClickFlag.SELECTION_MOVE + and __last_mouse_pos is not None + and __left_mouse_down + and event.type() == QEvent.MouseMove): + + dx = abs(__last_mouse_pos[0] - event.x()) + dy = abs(__last_mouse_pos[1] - event.y()) + + for p in PointManager.point_set.points: + if p.selected: + # Use the deltas to decide what direction to move. + # We only want to move in small unit increments. + # If we used the deltas directly the points would + # fly off screen quickly as we got farther from our + # start. + try: + if event.x() < __last_mouse_pos[0]: + p.move(-dx, 0) + if event.y() < __last_mouse_pos[1]: + p.move(0, -dy) + if event.x() > __last_mouse_pos[0]: + p.move(dx, 0) + if event.y() > __last_mouse_pos[1]: + p.move(0, dy) + + except ExceededWindowBoundsError: + # This point has indicated a move would exceed + # it's bounds, so we'll just go to the next + # point. + continue + + __last_mouse_pos = (event.x(), event.y()) + + elif (__left_click_flag is not __ClickFlag.NONE and + event.type() == QEvent.MouseButtonRelease): + + if __left_click_flag is __ClickFlag.SELECTION_BOX: + + set_move_bb_bottom_right(event.x(), event.y()) + + # Satisfy the post condition by resetting the bounding box + reset_move_bbs() + + ctx.opengl_widget.update() + + +def __handle_delete_point(ctx, event): + + __handle_info_updates(ctx, event) + + if (event.button() == Qt.LeftButton and + event.type() == QEvent.MouseButtonPress): + + set_drawing_event(event) + + PointManager.point_set.remove_point(event.x(), event.y()) + + refresh_point_list(ctx) + + ctx.opengl_widget.update() + ctx.point_list_widget.update() + + +def __handle_info_updates(ctx, event): + """ + Updates data under the "information" header. + + @param ctx The context to the main window. + @param event The event. + """ + if event.type() == QEvent.MouseMove: + 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 + 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.group_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: + point.color = Color.GREY + + +def generate_random_points(point_count, x_bound, y_bound): + """ + Using the random module of python generate a unique set of xs and ys + to use as points, bounded by the canvas edges. + + @param point_count The count of points to generate. + @param x_bound The width bound. + @param y_bound The height bound. + """ + + # TODO: The window size should be increased slightly to + # accomodate 3000 points (the maximum) given the point size. + # Work out an algorithm and limit the number of points + # selectable based on the maximum amount of points on the screen + # given the point size. + + # First clear the point set + PointManager.point_set.clear() + + point_size = PointManager.point_set.point_size + + # Sample without replacement so points are not duplicated. + xs = random.sample(range(point_size, x_bound), point_count) + + ys = random.sample(range(point_size, y_bound), point_count) + + points = list(zip(xs, ys)) + + for point in points: + PointManager.point_set.add_point(point[0], point[1], Color.GREY) + + +# Simple dispatcher to make it easy to dispatch the right mode +# function when the OpenGL window is acted on. +MODE_HANDLER_MAP = { + Mode.OFF: __handle_info_updates, + Mode.LOADED: __handle_info_updates, + Mode.ADD: __handle_add_point, + Mode.EDIT: __handle_edit_point, + Mode.MOVE: __handle_move_points, + Mode.DELETE: __handle_delete_point, + Mode.CHOOSE_CENTROIDS: __handle_choose_centroids +} diff --git a/clusterview2/ui/opengl_widget.py b/clusterview2/ui/opengl_widget.py new file mode 100644 index 0000000..36a9614 --- /dev/null +++ b/clusterview2/ui/opengl_widget.py @@ -0,0 +1,389 @@ +""" +This module defines functions that need to be overwritten +in order for OpenGL to work with the main window. This +module is named the same as the actual widget in order +to make namespacing consistent. + +To be clear, the actual widget is defined in the UI +generated code - `clusterview_ui.py`. The functions +here are imported as overrides to the OpenGL functions of +that widget. + +It should be split up into a few more separate files eventually... +Probably even into it's own module folder. +""" + +from OpenGL.GL import (glBegin, glClearColor, glColor3f, + glEnd, GL_LINE_LOOP, GL_POINTS, + glPointSize, glVertex3f, glViewport) + +from clusterview2.colors import Color, COLOR_TO_RGBA +from clusterview2.exceptions import (handle_exceptions, + InvalidStateError) +from clusterview2.mode import Mode +from clusterview2.point_manager import PointManager + +# Constants set based on the size of the window. +__BOTTOM_LEFT = (0, 0) +__WIDTH = None +__HEIGHT = None + +# State variables for a move selection bounding box. +# There are always reset to None after a selection has been made. +__move_bb_top_left = None +__move_bb_bottom_right = None + +# Module-global state variables for our drawing +# state machine. +# +# Below functions have to mark these as `global` so +# the interpreter knows that the variables are not +# function local. +__current_context = None +__current_event = None + + +def set_drawing_context(ctx): + """ + Sets the drawing context so that drawing functions can properly + interact with the widget. + """ + global __current_context + + __current_context = ctx + + +def set_drawing_event(event): + """ + State machine event management function. + + @param event The event. + """ + global __current_context + global __current_event + + if __current_context is None: + raise InvalidStateError("Drawing context must be set before setting " + + "drawing mode") + + if event is not None: + __current_event = event + + +def mouse_leave(ctx, event): + """ + The leave event for the OpenGL widget to properly reset the mouse + position label. + + @param ctx The context. + @param event The event. + """ + ctx.mouse_position_label.setText('') + + +def set_move_bb_top_left(x, y): + """ + Called to set the move bounding box's top left corner. + + @param x The x-coordinate. + @param y The y-coordinate. + """ + global __move_bb_top_left + + __move_bb_top_left = (x, y) + + +def set_move_bb_bottom_right(x, y): + """ + Called to set the move bounding box's bottom right corner. + + @param x The x-coordinate. + @param y The y-coordinate. + """ + global __move_bb_bottom_right + + __move_bb_bottom_right = (x, y) + + +def get_bb_top_left(): + return __move_bb_top_left + + +def get_bb_bottom_right(): + return __move_bb_bottom_right + + +def reset_move_bbs(): + global __move_bb_top_left + global __move_bb_bottom_right + + __move_bb_top_left = None + __move_bb_bottom_right = None + + +def initialize_gl(): + """ + Initializes the OpenGL context on the Window. + """ + + # Set white background + glClearColor(255, 255, 255, 0) + + +def resize_gl(w, h): + """ + OpenGL resize handler used to get the current viewport size. + + @param w The new width. + @param h The new height. + """ + global __WIDTH + global __HEIGHT + + __WIDTH = __current_context.opengl_widget.width() + __HEIGHT = __current_context.opengl_widget.height() + + +def viewport_width(): + return __WIDTH + + +def viewport_height(): + return __HEIGHT + + +@handle_exceptions +def paint_gl(): + """ + Stock PaintGL function from OpenGL that switches + on the current mode to determine what action to + perform on the current event. + """ + if(__current_context.mode is Mode.OFF and + not PointManager.point_set.empty()): + + # We want to redraw on any change to Mode.OFF so points are preserved - + # without this, any switch to Mode.OFF will cause a blank screen to + # render. + draw_points(PointManager.point_set) + + if (__current_context.mode in [Mode.ADD, Mode.EDIT, + Mode.MOVE, Mode.DELETE] and + __current_event is None and PointManager.point_set.empty()): + return + + if (__current_context.mode in [Mode.ADD, Mode.EDIT, Mode.DELETE] and + PointManager.point_set.empty()): + return + + 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.GROUP): + + draw_points(PointManager.point_set) + + elif __current_context.mode is Mode.EDIT: + raise NotImplementedError("Drawing for EDIT not implemented.") + + elif __current_context.mode is Mode.MOVE: + # We have to repeatedly draw the points while we are showing the + # move box. + if not PointManager.point_set.empty(): + draw_points(PointManager.point_set) + + draw_selection_box(Color.BLACK) + + if (__move_bb_top_left is not None and + __move_bb_bottom_right is not None): + + # Mark points that are selected in the bounding box + # and draw them using the normal function + highlight_selection() + draw_points(PointManager.point_set) + + +def __clamp_x(x): + """ + X-coordinate clamping function that goes from mouse coordinates to + OpenGL coordinates. + + @param x The x-coordinate to clamp. + @returns The clamped x coordinate. + """ + x_w = (x / (__WIDTH / 2.0) - 1.0) + return x_w + + +def __clamp_y(y): + """ + Y-coordinate clamping function that goes from mouse coordinates to + OpenGL coordinates. + + @param y The y-coordinate to clamp. + @returns The clamped y coordinate. + """ + y_w = -1.0 * (y / (__HEIGHT / 2.0) - 1.0) + return y_w + + +def box_hit(tx, ty, x1, y1, x2, y2): + """ + Calculates whether or not a given point collides with the given bounding + box. + + @param tx The target x. + @param ty The target y. + @param x1 The top left x. + @param y1 The top left y. + @param x2 The bottom left x. + @param y2 The bottom left y. + """ + + # The box in this case is flipped - the user started at the bottom right + # corner. Pixel-wise top left is (0, 0) and bottom right is + # (screen_x, screen_y) + if x1 > x2 and y1 > y2: + return (tx <= x1 and + tx >= x2 and + ty <= y1 and + ty >= y2) + + # The box in this case started from the top right + if x1 > x2 and y1 < y2: + return (tx <= x1 and + tx >= x2 and + ty >= y1 and + ty <= y2) + + # The box in this case started from the bottom left + if x1 < x2 and y1 > y2: + return (tx >= x1 and + tx <= x2 and + ty <= y1 and + ty >= y2) + + # Normal condition: Box starts from the top left + return (tx >= x1 and + tx <= x2 and + ty >= y1 and + ty <= y2) + + +def highlight_selection(): + """ + Given the current move bounding box, highlights any points inside it. + """ + + top_left = get_bb_top_left() + bottom_right = get_bb_bottom_right() + + for point in PointManager.point_set.points: + if box_hit(point.x, point.y, top_left[0], top_left[1], + bottom_right[0], bottom_right[1]): + + point.select() + else: + point.unselect() + + +def draw_selection_box(color): + """ + When the move bounding box state is populated and the mode is set + to MODE.Move this function will draw the selection bounding box. + + @param color The color Enum. + """ + global __current_context + + if __current_context is None: + raise InvalidStateError("Drawing context must be set before setting " + + "drawing mode") + + if not isinstance(color, Color): + raise ValueError("Color must exist in the Color enumeration") + + if __move_bb_top_left is None or __move_bb_bottom_right is None: + # Nothing to draw. + return + + ct = COLOR_TO_RGBA[color] + + glViewport(0, 0, __WIDTH, __HEIGHT) + + # Top right corner has the same x as the bottom right + # and same y as the top left. + top_right_corner = (__move_bb_bottom_right[0], __move_bb_top_left[1]) + + # Bottom left corner has the same x as the top left and + # same y as the bottom right. + bottom_left_corner = (__move_bb_top_left[0], __move_bb_bottom_right[1]) + + glBegin(GL_LINE_LOOP) + glColor3f(ct[0], ct[1], ct[2]) + + glVertex3f(__clamp_x(__move_bb_top_left[0]), + __clamp_y(__move_bb_top_left[1]), + 0.0) + + glVertex3f(__clamp_x(top_right_corner[0]), + __clamp_y(top_right_corner[1]), + 0.0) + + glVertex3f(__clamp_x(__move_bb_bottom_right[0]), + __clamp_y(__move_bb_bottom_right[1]), + 0.0) + + glVertex3f(__clamp_x(bottom_left_corner[0]), + __clamp_y(bottom_left_corner[1]), + 0.0) + + glEnd() + + +def clear_selection(): + """ + A helper designed to be called from the main window + in order to clear the selection internal to the graphics + and mode files. This way you dont have to do something + before the selection clears. + """ + if not PointManager.point_set.empty(): + PointManager.point_set.clear_selection() + + +def draw_points(point_set): + """ + Simple point drawing function. + + Given a coordinate (x, y), and a Color enum this + function will draw the given point with the given + color. + + @param point_set The PointSet to draw. + @param color The Color Enum. + """ + global __current_context + + if __current_context is None: + raise InvalidStateError("Drawing context must be set before setting " + + "drawing mode") + + glViewport(0, 0, __WIDTH, __HEIGHT) + glPointSize(PointManager.point_set.point_size) + + glBegin(GL_POINTS) + for point in point_set.points: + + if point.selected: + blue = COLOR_TO_RGBA[Color.BLUE] + glColor3f(blue[0], blue[1], blue[2]) + else: + ct = COLOR_TO_RGBA[point.color] + glColor3f(ct[0], ct[1], ct[2]) + + glVertex3f(__clamp_x(point.x), + __clamp_y(point.y), + 0.0) # Z is currently fixed to 0 + glEnd() diff --git a/clusterview2/ui/point_list_widget.py b/clusterview2/ui/point_list_widget.py new file mode 100644 index 0000000..6df8574 --- /dev/null +++ b/clusterview2/ui/point_list_widget.py @@ -0,0 +1,53 @@ +""" +Similar to the opengl_widget module, this module defines +helper functions for the point_list_widget. It is named +the same for convenience. The actual point_list_widget +is defined in the clusterview_ui.py file. +""" + +from clusterview2.point_manager import PointManager + + +def __string_point_to_point(str_point): + """ + In the QListWidget points are stored as strings + because of the way Qt has list items defined. + + @param str_point The string of the form (x, y) to convert. + """ + + # 1. Split + elems = str_point.split(",") + + # 2. Take elements "(x" and "y)" and remove their first and + # last characters, respectively. Note that for y this + # function expects there to be a space after the comma. + x = elems[0][1:] + y = elems[1][1:-1] + + return (int(x), int(y)) + + +def item_click_handler(ctx, item): + """ + Handles an item becoming clicked in the list. + + This function is designed to be partially applied with the + main_window context in order to be able to trigger an opengl_widget + refresh. + + @param ctx The context. + @param item The clicked item. + """ + point = __string_point_to_point(item.text()) + + # TODO: Super slow linear search, should write a find_point function + # on the point_set in order to speed this up since PointSet + # is backed by a set anyway. + for p in PointManager.point_set.points: + if p.x == point[0] and p.y == point[1]: + p.select() + else: + p.unselect() + + ctx.opengl_widget.update() diff --git a/clusterview2_ui.py b/clusterview2_ui.py new file mode 100644 index 0000000..4fad721 --- /dev/null +++ b/clusterview2_ui.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'clusterview.ui' +# +# Created by: PyQt5 UI code generator 5.13.0 +# +# WARNING! All changes made in this file will be lost! + + +from PyQt5 import QtCore, QtGui, QtWidgets + + +class Ui_MainWindow(object): + def setupUi(self, MainWindow): + MainWindow.setObjectName("MainWindow") + MainWindow.resize(1280, 720) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Minimum) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(MainWindow.sizePolicy().hasHeightForWidth()) + MainWindow.setSizePolicy(sizePolicy) + MainWindow.setMinimumSize(QtCore.QSize(1280, 720)) + MainWindow.setMaximumSize(QtCore.QSize(1280, 720)) + self.centralwidget = QtWidgets.QWidget(MainWindow) + self.centralwidget.setObjectName("centralwidget") + self.horizontalLayout = QtWidgets.QHBoxLayout(self.centralwidget) + self.horizontalLayout.setObjectName("horizontalLayout") + self.opengl_widget = QtWidgets.QOpenGLWidget(self.centralwidget) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.opengl_widget.sizePolicy().hasHeightForWidth()) + self.opengl_widget.setSizePolicy(sizePolicy) + self.opengl_widget.setMaximumSize(QtCore.QSize(900, 16777215)) + self.opengl_widget.setObjectName("opengl_widget") + self.horizontalLayout.addWidget(self.opengl_widget) + self.verticalLayout = QtWidgets.QVBoxLayout() + self.verticalLayout.setObjectName("verticalLayout") + self.groupBox = QtWidgets.QGroupBox(self.centralwidget) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Minimum) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.groupBox.sizePolicy().hasHeightForWidth()) + self.groupBox.setSizePolicy(sizePolicy) + self.groupBox.setMinimumSize(QtCore.QSize(100, 0)) + self.groupBox.setMaximumSize(QtCore.QSize(200, 200)) + self.groupBox.setObjectName("groupBox") + self.gridLayout = QtWidgets.QGridLayout(self.groupBox) + self.gridLayout.setObjectName("gridLayout") + self.point_list_widget = QtWidgets.QListWidget(self.groupBox) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.point_list_widget.sizePolicy().hasHeightForWidth()) + self.point_list_widget.setSizePolicy(sizePolicy) + self.point_list_widget.setMinimumSize(QtCore.QSize(100, 0)) + self.point_list_widget.setObjectName("point_list_widget") + self.gridLayout.addWidget(self.point_list_widget, 0, 0, 1, 1) + self.verticalLayout.addWidget(self.groupBox) + self.groupBox_3 = QtWidgets.QGroupBox(self.centralwidget) + self.groupBox_3.setObjectName("groupBox_3") + self.formLayout = QtWidgets.QFormLayout(self.groupBox_3) + self.formLayout.setObjectName("formLayout") + 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) + 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) + self.solve_button = QtWidgets.QPushButton(self.groupBox_3) + self.solve_button.setEnabled(False) + self.solve_button.setObjectName("solve_button") + self.formLayout.setWidget(5, QtWidgets.QFormLayout.LabelRole, self.solve_button) + self.group_button = QtWidgets.QPushButton(self.groupBox_3) + self.group_button.setEnabled(False) + self.group_button.setObjectName("group_button") + self.formLayout.setWidget(4, QtWidgets.QFormLayout.LabelRole, self.group_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.verticalLayout.addWidget(self.groupBox_3) + spacerItem = QtWidgets.QSpacerItem(20, 20, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) + self.verticalLayout.addItem(spacerItem) + self.groupBox_2 = QtWidgets.QGroupBox(self.centralwidget) + self.groupBox_2.setObjectName("groupBox_2") + self.gridLayout_2 = QtWidgets.QGridLayout(self.groupBox_2) + self.gridLayout_2.setObjectName("gridLayout_2") + self.mouse_position_label = QtWidgets.QLabel(self.groupBox_2) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.mouse_position_label.sizePolicy().hasHeightForWidth()) + self.mouse_position_label.setSizePolicy(sizePolicy) + self.mouse_position_label.setMinimumSize(QtCore.QSize(100, 0)) + self.mouse_position_label.setText("") + self.mouse_position_label.setObjectName("mouse_position_label") + self.gridLayout_2.addWidget(self.mouse_position_label, 0, 3, 1, 1) + spacerItem1 = QtWidgets.QSpacerItem(20, 20, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.gridLayout_2.addItem(spacerItem1, 1, 0, 1, 1) + self.label = QtWidgets.QLabel(self.groupBox_2) + self.label.setObjectName("label") + self.gridLayout_2.addWidget(self.label, 0, 0, 1, 1) + spacerItem2 = QtWidgets.QSpacerItem(20, 20, QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Minimum) + self.gridLayout_2.addItem(spacerItem2, 0, 2, 1, 1) + self.verticalLayout.addWidget(self.groupBox_2) + self.horizontalLayout.addLayout(self.verticalLayout) + MainWindow.setCentralWidget(self.centralwidget) + self.menubar = QtWidgets.QMenuBar(MainWindow) + self.menubar.setGeometry(QtCore.QRect(0, 0, 1280, 28)) + self.menubar.setNativeMenuBar(True) + self.menubar.setObjectName("menubar") + self.menu_file = QtWidgets.QMenu(self.menubar) + self.menu_file.setObjectName("menu_file") + self.menu_help = QtWidgets.QMenu(self.menubar) + self.menu_help.setObjectName("menu_help") + MainWindow.setMenuBar(self.menubar) + self.status_bar = QtWidgets.QStatusBar(MainWindow) + self.status_bar.setObjectName("status_bar") + MainWindow.setStatusBar(self.status_bar) + self.tool_bar = QtWidgets.QToolBar(MainWindow) + self.tool_bar.setMovable(False) + self.tool_bar.setObjectName("tool_bar") + MainWindow.addToolBar(QtCore.Qt.LeftToolBarArea, self.tool_bar) + self.action_add_points = QtWidgets.QAction(MainWindow) + self.action_add_points.setObjectName("action_add_points") + self.action_edit_points = QtWidgets.QAction(MainWindow) + self.action_edit_points.setObjectName("action_edit_points") + self.action_delete_points = QtWidgets.QAction(MainWindow) + self.action_delete_points.setObjectName("action_delete_points") + self.action_solve = QtWidgets.QAction(MainWindow) + self.action_solve.setObjectName("action_solve") + self.action_move_points = QtWidgets.QAction(MainWindow) + self.action_move_points.setObjectName("action_move_points") + self.action_save_point_configuration = QtWidgets.QAction(MainWindow) + self.action_save_point_configuration.setObjectName("action_save_point_configuration") + self.action_load_point_configuration = QtWidgets.QAction(MainWindow) + self.action_load_point_configuration.setObjectName("action_load_point_configuration") + self.action_exit = QtWidgets.QAction(MainWindow) + self.action_exit.setObjectName("action_exit") + self.action_generate_random_points = QtWidgets.QAction(MainWindow) + self.action_generate_random_points.setObjectName("action_generate_random_points") + self.menu_file.addAction(self.action_load_point_configuration) + self.menu_file.addAction(self.action_save_point_configuration) + self.menu_file.addSeparator() + self.menu_file.addAction(self.action_exit) + self.menubar.addAction(self.menu_file.menuAction()) + self.menubar.addAction(self.menu_help.menuAction()) + self.tool_bar.addAction(self.action_generate_random_points) + self.tool_bar.addAction(self.action_add_points) + self.tool_bar.addAction(self.action_move_points) + self.tool_bar.addAction(self.action_edit_points) + self.tool_bar.addAction(self.action_delete_points) + + self.retranslateUi(MainWindow) + QtCore.QMetaObject.connectSlotsByName(MainWindow) + + def retranslateUi(self, MainWindow): + _translate = QtCore.QCoreApplication.translate + MainWindow.setWindowTitle(_translate("MainWindow", "ClusterView")) + 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.solve_button.setText(_translate("MainWindow", "Solve")) + self.group_button.setText(_translate("MainWindow", "Group")) + 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")) + self.menu_help.setTitle(_translate("MainWindow", "Help")) + self.tool_bar.setWindowTitle(_translate("MainWindow", "toolBar")) + self.action_add_points.setText(_translate("MainWindow", "Add Points")) + self.action_add_points.setToolTip(_translate("MainWindow", "Enables point adding mode.")) + self.action_add_points.setShortcut(_translate("MainWindow", "Ctrl+A")) + self.action_edit_points.setText(_translate("MainWindow", "Edit Points")) + self.action_edit_points.setToolTip(_translate("MainWindow", "Enables point editing mode.")) + self.action_edit_points.setShortcut(_translate("MainWindow", "Ctrl+E")) + self.action_delete_points.setText(_translate("MainWindow", "Delete Points")) + self.action_delete_points.setToolTip(_translate("MainWindow", "Enables point deletion mode.")) + self.action_delete_points.setShortcut(_translate("MainWindow", "Ctrl+D")) + self.action_solve.setText(_translate("MainWindow", "Solve")) + self.action_solve.setToolTip(_translate("MainWindow", "Opens the solve dialog to choose a solving solution.")) + self.action_solve.setShortcut(_translate("MainWindow", "Ctrl+S")) + self.action_move_points.setText(_translate("MainWindow", "Move Points")) + self.action_move_points.setToolTip(_translate("MainWindow", "Enables the movement of a selection of points.")) + self.action_save_point_configuration.setText(_translate("MainWindow", "Save Point Configuration")) + self.action_load_point_configuration.setText(_translate("MainWindow", "Load Point Configuration")) + self.action_exit.setText(_translate("MainWindow", "Exit")) + self.action_generate_random_points.setText(_translate("MainWindow", "Generate Random Points")) diff --git a/main_window.py b/main_window.py new file mode 100644 index 0000000..1d84087 --- /dev/null +++ b/main_window.py @@ -0,0 +1,259 @@ +from functools import partial + +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QCursor +from PyQt5.QtWidgets import QFileDialog, QInputDialog, QMainWindow + +from clusterview2.exceptions import handle_exceptions +from clusterview2.colors import Color +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, + generate_random_points) +from clusterview2.ui.opengl_widget import (clear_selection, initialize_gl, + mouse_leave, paint_gl, resize_gl, + set_drawing_context) +from clusterview2.point_manager import PointManager +from clusterview2.ui.point_list_widget import item_click_handler +from clusterview2_ui import Ui_MainWindow + + +class MainWindow(QMainWindow, Ui_MainWindow): + """ + A wrapper class for handling creating a window based + on the `clusterview_ui.py` code generated from + `clusterview.ui`. + """ + + # This is a static mode variable since there will only ever + # be one MainWindow. + __mode = Mode.OFF + + def __init__(self, parent=None): + super(MainWindow, self).__init__(parent) + self.setupUi(self) + + # Size of point for drawing + self.__point_size = 8 + + # TODO: THESE ARE HARD CODED TO THE CURRENT QT WIDGET SIZES + # FIX THIS PROPERLY WITH A RESIZE EVENT DETECT. + # PointManager is a class that is filled with static methods + # designed for managing state. + self.__viewport_width = 833 + self.__viewport_height = 656 + + PointManager.point_set = PointSet(self.__point_size, + self.__viewport_width, + self.__viewport_height) + + # Spin box should only allow the number of centroids 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) + + # We only need to set the context in our OpenGL state machine + # wrapper once here since the window is fixed size. + # If we allow resizing of the window, the context must be updated + # each resize so that coordinates are converted from screen (x, y) + # to OpenGL coordinates properly. + set_drawing_context(self) + + # Enables mouse tracking on the viewport so mouseMoveEvents are + # tracked and fired properly. + self.opengl_widget.setMouseTracking(True) + + # Enable keyboard input capture on the OpenGL Widget + self.opengl_widget.setFocusPolicy(Qt.StrongFocus) + + # Here we partially apply the key press handler with self to + # create a new function that only expects the event `keyPressEvent` + # expects. In this way, we've snuck the state of the opengl_widget + # into the function so that we can modify it as we please. + self.opengl_widget.keyPressEvent = partial(ogl_keypress_handler, self) + + # Same story here but this time with the itemClicked event + # so that when an element is clicked on in the point list it will + # highlight. + self.point_list_widget.itemClicked.connect(partial(item_click_handler, + self)) + + self.choose_centroids_button.clicked.connect(self.__choose_centroids) + + self.group_button.clicked.connect(self.__group) + + self.reset_button.clicked.connect(self.__reset_grouping) + + # ----------------------------------------------- + # OpenGL Graphics Handlers are set + # here and defined in clusterview.opengl_widget. + # ----------------------------------------------- + self.opengl_widget.initializeGL = initialize_gl + self.opengl_widget.paintGL = paint_gl + self.opengl_widget.resizeGL = resize_gl + self.opengl_widget.leaveEvent = partial(mouse_leave, self) + + # ------------------------------------- + # UI Handlers + # ------------------------------------- + self.action_add_points.triggered.connect(self.__add_points) + self.action_edit_points.triggered.connect(self.__edit_points) + self.action_delete_points.triggered.connect(self.__delete_points) + self.action_move_points.triggered.connect(self.__move_points) + + (self.action_generate_random_points + .triggered.connect(self.__generate_random_points)) + + self.action_save_point_configuration.triggered.connect( + self.__save_points_file) + + self.action_load_point_configuration.triggered.connect( + self.__open_points_file) + + self.action_exit.triggered.connect(self.__close_event) + + # Override handler for mouse press so we can draw points based on + # the OpenGL coordinate system inside of the OpenGL Widget. + self.opengl_widget.mousePressEvent = self.__ogl_click_dispatcher + self.opengl_widget.mouseMoveEvent = self.__ogl_click_dispatcher + self.opengl_widget.mouseReleaseEvent = self.__ogl_click_dispatcher + + # ----------------------------------------------------------------- + # Mode changers - these will be used to signal the action in the + # OpenGL Widget. + # ----------------------------------------------------------------- + def __off_mode(self): + self.__mode = Mode.OFF + self.opengl_widget.setCursor(QCursor(Qt.CursorShape.ArrowCursor)) + self.status_bar.showMessage("") + clear_selection() + self.opengl_widget.update() + + def __add_points(self): + self.__mode = Mode.ADD + self.opengl_widget.setCursor(QCursor(Qt.CursorShape.CrossCursor)) + self.status_bar.showMessage("ADD MODE") + clear_selection() + self.opengl_widget.update() + + def __edit_points(self): + self.__mode = Mode.EDIT + self.opengl_widget.setCursor(QCursor(Qt.CursorShape.CrossCursor)) + self.status_bar.showMessage("EDIT MODE") + clear_selection() + self.opengl_widget.update() + + def __delete_points(self): + self.__mode = Mode.DELETE + self.opengl_widget.setCursor(QCursor( + Qt.CursorShape.PointingHandCursor)) + self.status_bar.showMessage("DELETE MODE") + clear_selection() + self.opengl_widget.update() + + def __move_points(self): + self.__mode = Mode.MOVE + self.opengl_widget.setCursor(QCursor(Qt.CursorShape.SizeAllCursor)) + self.status_bar.showMessage("MOVE MODE - PRESS ESC OR SWITCH MODES" + + "TO CANCEL SELECTION") + 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 __group(self): + self.__mode = Mode.GROUP + + self.opengl_widget.setCursor(QCursor(Qt.CursorShape.ArrowCursor)) + self.status_bar.showMessage("GROUPING") + clear_selection() + group(self) + self.__off_mode() + self.opengl_widget.update() + + def __reset_grouping(self): + self.__off_mode() + self.number_of_centroids.setEnabled(True) + self.number_of_centroids.setValue(0) + self.choose_centroids_button.setEnabled(True) + self.solve_button.setEnabled(False) + self.group_button.setEnabled(False) + PointManager.centroids = [] + reset_centroid_count_and_colors() + + def __generate_random_points(self): + value, ok = QInputDialog.getInt(self, "Number of Points", + "Number of Points:", 30, 30, 3000, 1) + + if ok: + self.__mode = Mode.ADD + generate_random_points(value, + (self.__viewport_width - self.__point_size), + (self.__viewport_height - self.__point_size) + ) + self.__mode = Mode.OFF + + self.opengl_widget.update() + + refresh_point_list(self) + + @property + def mode(self): + """ + Function designed to be used from a context + to get the current mode. + """ + return self.__mode + + @mode.setter + def mode(self, mode): + self.__mode = mode + + def __close_event(self, event): + import sys + sys.exit(0) + + def __open_points_file(self): + ofile, _ = QFileDialog.getOpenFileName(self, + "Open Point Configuration", + "", + "JSON files (*.json)") + if ofile: + self.__mode = Mode.LOADED + + PointManager.load(ofile) + + self.opengl_widget.update() + + refresh_point_list(self) + + def __save_points_file(self): + file_name, _ = (QFileDialog. + getSaveFileName(self, + "Save Point Configuration", + "", + "JSON Files (*.json)")) + if file_name: + PointManager.save(file_name) + + @handle_exceptions + def __ogl_click_dispatcher(self, event): + """ + Mode dispatcher for click actions on the OpenGL widget. + """ + # Map from Mode -> function + # where the function is a handler for the + # OpenGL event. The context passed to these functions allows + # them to modify on screen widgets such as the QOpenGLWidget and + # QListWidget. + MODE_HANDLER_MAP[self.__mode](self, event) diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..9ddbe1d --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,8 @@ +-r requirements.txt + +flake8==3.7.8 +mypy==0.730 +coverage==4.5.4 +pytest==5.0.1 +pytest-cov==2.7.1 +ipython==7.7.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0a75791 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +PyOpenGL==3.1.0 +PyOpenGL-accelerate==3.1.3b1 +PyQt5==5.13.0 +PyQt5-sip==4.19.18 + +# Add the kmeans thing here. From 4c3be0b80eba723420c5331b215e48c23dd2f083 Mon Sep 17 00:00:00 2001 From: Taylor Bockman Date: Tue, 15 Oct 2019 21:05:26 -0700 Subject: [PATCH 2/8] Clean up clusterview old code a little bit, first launch without the grouping stuff. --- README.md | 8 + TODO.org | 13 +- clusterview2.ui | 28 +-- clusterview2/exceptions.py | 10 +- clusterview2/mode.py | 5 +- clusterview2/points.py | 364 +++++++++++++++++++++++++++++++++++ clusterview2/ui/mode_handlers.py | 136 ++++++------- clusterview2/ui/opengl_widget.py | 23 ++- clusterview2/ui/point_list_widget.py | 4 +- clusterview2_ui.py | 26 +-- main_window.py | 143 +++++++------- setup.cfg | 2 + 12 files changed, 575 insertions(+), 187 deletions(-) create mode 100644 clusterview2/points.py create mode 100644 setup.cfg diff --git a/README.md b/README.md index 3bbf649..8db6d64 100644 --- a/README.md +++ b/README.md @@ -12,3 +12,11 @@ TODO Make sure to install the development requirements using `pip install -r requirements-dev.txt`. This will install all main requirements as well as useful testing and linting tools. + +### Regenerating the UI + +After modifying the `*.ui` file in Qt Designer run + +`pyuic5 clusterview2.ui -o clusterview2_ui.py` + +to regenerate the UI python file. diff --git a/TODO.org b/TODO.org index 0580c9b..7750051 100644 --- a/TODO.org +++ b/TODO.org @@ -4,11 +4,14 @@ TODO: the math library from kmeans (add a test to dist). Additionally, the point and cluster of the clusterview should inherit from k-means point and cluster so that it can use the algorithm correctly. All other aspects of point and cluster in the clusterview program can be kept. - * Extract and improve the overall structure of clusterview so that globals are not used unless absolutely necessary. + * See note in opengl_widget. Make it a static class. + * Do the same thing you did for opengl_widget in mode_handlers, leave the MODE_HANDLERS constant global. + * Port over all tests from clusterview except the math tests. Improve point tests to include weight. * Turn kmeans into a python package and import it here. - * Remove old clusterview buttons, keep the status window side and saving feature. Add new buttons for weighted and - unweighted clustering. * Add a property to the point called weight, which is set when you click edit point. It will give a popup that - allows you to specify a point weight. - * Use kmeans to do the stuff. + allows you to specify a point weight. DEFAULT WEIGHT IS 1 + * Use kmeans to do the stuff - strip down point x y to refer to it's kmeans parent. * Weighted cluster mean is rendered as a hollow circle and unweighted k-means mean is a x. + * Saving should save the weights, load should load the weights. + * Make the current context exception string in opengl widget a constant and use that instead. + * Add typing to every function except setters and __XYZ__ functions (__init__ can still have typing) diff --git a/clusterview2.ui b/clusterview2.ui index 51d9afb..4d5c2f7 100644 --- a/clusterview2.ui +++ b/clusterview2.ui @@ -29,7 +29,7 @@ - ClusterView + ClusterView2 @@ -139,23 +139,13 @@ - - - - false - - - Solve - - - - + false - Group + Unweighted Clustering @@ -166,6 +156,16 @@ + + + + false + + + Weighted Clustering + + + @@ -259,7 +259,7 @@ 0 0 1280 - 28 + 21 diff --git a/clusterview2/exceptions.py b/clusterview2/exceptions.py index 5710393..ee0dfdc 100644 --- a/clusterview2/exceptions.py +++ b/clusterview2/exceptions.py @@ -2,12 +2,15 @@ from PyQt5.QtWidgets import QErrorMessage from clusterview2.mode import Mode + class ExceededWindowBoundsError(Exception): pass + class InvalidStateError(Exception): pass + class InvalidModeError(Exception): """ An exception to specify an invalid mode has been provided. @@ -20,12 +23,13 @@ class InvalidModeError(Exception): """ if not isinstance(mode, Mode): - raise ValueError("Mode argument to InvalidMode must be of " + - " type mode") + raise ValueError('Mode argument to InvalidMode must be of ' + + ' type mode') # Mode cases for invalid mode if mode == Mode.OFF: - super().__init__("You must select a mode before continuing.") + super().__init__('You must select a mode before continuing.') + def handle_exceptions(func): """ diff --git a/clusterview2/mode.py b/clusterview2/mode.py index c4b1ff4..e54cbe9 100644 --- a/clusterview2/mode.py +++ b/clusterview2/mode.py @@ -13,5 +13,6 @@ class Mode(Enum): MOVE = 3 DELETE = 4 LOADED = 5 - CHOOSE_CENTROIDS = 6 # TODO: Can replace with choose weighted or something - GROUP = 7 + CHOOSE_CENTROIDS = 6 + UNWEIGHTED_CLUSTERING = 7 + WEIGHTED_CLUSTERING = 8 diff --git a/clusterview2/points.py b/clusterview2/points.py new file mode 100644 index 0000000..db933fb --- /dev/null +++ b/clusterview2/points.py @@ -0,0 +1,364 @@ +from math import floor + +from .colors import Color +from .exceptions import ExceededWindowBoundsError + +# TODO: THIS WILL NEED TO BE MODIFIED TO INHEIRIT THE KMEANS POINT CLASS. + +class Point: + """ + A class representing a point. A point + has a point_size bounding box around + it. + """ + + def __init__(self, x, y, color, point_size, + viewport_width, viewport_height): + """ + Initializes a new point with a point_size bounding box, viewport + awareness, and a color. + + Initialized with additional viewport data to make sure the + move function refuses to move a point outside the screen. + + @param x The x-coordinate. + @param y The y-coordinate. + @param color The color of the point. + @param point_size The size of the point in pixels. + @param viewport_width The width of the viewport. + @param viewport_height The height of the viewport. + """ + + if not isinstance(color, Color): + raise ValueError("Point must be initialized with a color of " + + "type Color.") + + self.__point_size = point_size + self.__x = x + self.__y = y + + self.__color = color + + self.__viewport_width = viewport_width + self.__viewport_height = viewport_height + + self.__calculate_hitbox() + + self.__check_window_bounds(x, y) + + self.__selected = False + + self.__attributes = [] + + @property + def x(self): + return self.__x + + @property + def y(self): + return self.__y + + @property + def point_size(self): + return self.__point_size + + @property + def selected(self): + return self.__selected + + @property + def color(self): + return self.__color + + @color.setter + def color(self, color): + if not isinstance(color, Color): + raise ValueError('Point color must be of type Color.') + + self.__color = color + + @property + def attributes(self): + return self.__attributes + + def add_attribute(self, attr): + self.__attributes.append(attr) + + def __calculate_hitbox(self): + """ + Calculates the hit box for the point given the current + position (center) and the point size. + """ + half_point = floor(self.point_size / 2.0) + + self.__top_left_corner = (self.__x - half_point, + self.__y + half_point) + + self.__bottom_right_corner = (self.__x + half_point, + self.__y - half_point) + + def __check_window_bounds(self, x, y): + """ + Simple window bound check that raises an exception when + the point (x, y) exceeds the known viewport bounds. + + @param x The x-coordinate under test. + @param y The y-coordinate under test. + @raises ExceededWindowBoundsError If the viewport bounds are exceeded. + """ + half_point = floor(self.point_size / 2.0) + + # Screen size in pixels is always positive + # We need to include the half point here because + # the (x, y) for a point is the center of the square and we + # do not want the EDGES to exceed the viewport bounds. + if (x > self.__viewport_width - half_point or + y > self.__viewport_height - half_point or + x < half_point or + y < half_point): + + raise ExceededWindowBoundsError + + def move(self, dx, dy): + """ + Adds the deltas dx and dy to the point. + + @param dx The delta in the x direction. + @param dy The delta in the y direction. + """ + + self.__check_window_bounds(self.__x + dx, self.__y + dy) + + self.__x += dx + self.__y += dy + + # It's important to note as we move the point we need to + # make sure we are constantly updating it's hitbox. + self.__calculate_hitbox() + + def __eq__(self, other): + """ + Override for class equality. + + @param other The other object. + """ + return (self.__x == other.x and + self.__y == other.y and + self.__color == other.color and + self.__attributes == other.attributes and + self.__point_size == other.point_size) + + def __repr__(self): + + # For some reason I had to split this instead of using one giant + # string chained with `+` inside of `()`. + s = "= self.__top_left_corner[0] and + x <= self.__bottom_right_corner[0] and + y <= self.__top_left_corner[1] and + y >= self.__bottom_right_corner[1]) + + +class Attribute: + + def __init__(self, name, value): + """ + Initializes an attribute. + """ + self.__name = name + self.__value = value + + +class PointSet: + """ + Useful container for points. Since points are not hashable (they are + modified in place by move) we are forced to back the PointSet with an + array. However, it is still a "set" in the "uniqueness among all points" + sense because `add_point` will reject a point with a duplicate center. + """ + + def __init__(self, point_size, viewport_width, viewport_height): + """ + Initializes a point container with points of size point_size. + + @param point_size The size of the points. + @param viewport_width The width of the viewport for bounds + calculations. + @param viewport_height The height of the viewport for bounds + calculations. + """ + self.__points = [] + self.__point_size = point_size + self.__viewport_width = viewport_width + self.__viewport_height = viewport_height + + def __eq__(self, other): + other_points = list(other.points) + + return (self.__points == other_points and + self.__point_size == other.point_size and + self.__viewport_width == other.viewport_width and + self.__viewport_height == other.viewport_height) + + def __repr__(self): + s = [] + + for p in self.__points: + s.append(str(p)) + + return ",".join(s) + + def clear(self): + self.__points = [] + + @property + def points(self): + """ + Getter for points. Returns a generator for + looping. + """ + for point in self.__points: + yield point + + @property + def point_size(self): + return self.__point_size + + @property + def viewport_height(self): + return self.__viewport_height + + @property + def viewport_width(self): + return self.__viewport_width + + @viewport_height.setter + def viewport_height(self, height): + self.__viewport_height = height + + @viewport_width.setter + def viewport_width(self, width): + self.__viewport_width = width + + def empty(self): + return len(self.__points) == 0 + + def clear_selection(self): + """ + Handy helper function to clear all selected points. + """ + for p in self.__points: + p.unselect() + + def add_point(self, x, y, color, attrs=[]): + """ + Adds a point in screen coordinates and an optional attribute to + the list. + + @param x The x-coordinate. + @param y The y-coordinate. + @param color The color of the point. + @param attr An optional attribute. + @raises ExceededWindowBoundsError If the point could not be constructed + because it would be outside the + window bounds. + """ + + if attrs != [] and not all(isinstance(x, Attribute) for x in attrs): + raise ValueError("Attributes in add_point must be an " + + "attribute array.") + + if not isinstance(color, Color): + raise ValueError("Point color must be a Color enum.") + + point = Point(x, y, color, self.__point_size, + self.__viewport_width, self.__viewport_height) + + for attr in attrs: + point.add_attribute(attr) + + if point in self.__points: + # Silently reject a duplicate point (same center). + return + + self.__points.append(point) + + def remove_point(self, x, y): + """ + Removes a point from the point set based on a bounding + box calculation. + + Removing a point is an exercise is determining which points + have been hit, and then pulling them out of the list. + + If two points have a section overlapping, and the user clicks + the overlapped section, both points will be removed. + + Currently O(n). + + @param x The x-coordinate. + @param y The y-coordinate. + """ + for p in self.__points: + if p.hit(x, y): + self.__points.remove(p) + + def groups(self): + """ + Returns a map from color to point representing each point's group + membership based on color. + """ + g = {} + + for p in self.__points: + if p.color not in g: + # Create the key for the group color since it does + # not exist. + g[p.color] = [] + + g[p.color].append(p) + + return g diff --git a/clusterview2/ui/mode_handlers.py b/clusterview2/ui/mode_handlers.py index 0980e6e..857f068 100644 --- a/clusterview2/ui/mode_handlers.py +++ b/clusterview2/ui/mode_handlers.py @@ -12,7 +12,7 @@ from .opengl_widget import (set_drawing_event, set_move_bb_top_left, from clusterview2.point_manager import PointManager -class __ClickFlag: +class _ClickFlag: # This is the first stage. On mouse release it goes to # SELECTION_MOVE. @@ -35,23 +35,23 @@ class __ClickFlag: # GLOBALS # Canvas pixel border - empirical, not sure where this is stored officially -__CANVAS_BORDER = 1 +_CANVAS_BORDER = 1 # Module level flag for left click events (used to detect a left # click hold drag) -__left_click_flag = __ClickFlag.NONE +_left_click_flag = _ClickFlag.NONE # Variable to track the mouse state during selection movement -__last_mouse_pos = None +_last_mouse_pos = None # Used to implement mouse dragging when clicked -__left_click_down = False +_left_click_down = False # TODO: WHEN THE GROUPING ENDS AND THE USER CANCELS THE CENTROID COUNT # SHOULD BE ZEROED AND REMAINING COLORS SHOULD BE REPOPULATED. # Count of centroids for comparison with the spin widget -__centroid_count = 0 -__remaining_colors = [c for c in Color if c not in [Color.BLUE, Color.GREY]] +_centroid_count = 0 +_remaining_colors = [c for c in Color if c not in [Color.BLUE, Color.GREY]] def refresh_point_list(ctx): @@ -62,7 +62,7 @@ def refresh_point_list(ctx): """ # In order to make some guarantees and avoid duplicate # data we will clear the point list widget and re-populate - # it using the current __point_set. + # it using the current _point_set. ctx.point_list_widget.clear() for p in PointManager.point_set.points: @@ -71,7 +71,7 @@ def refresh_point_list(ctx): ctx.point_list_widget.update() -def __handle_add_point(ctx, event): +def _handle_add_point(ctx, event): """ Event handler for the add point mode. @@ -84,7 +84,7 @@ def __handle_add_point(ctx, event): """ # Update information as needed - __handle_info_updates(ctx, event) + _handle_info_updates(ctx, event) if (event.button() == Qt.LeftButton and event.type() == QEvent.MouseButtonPress): @@ -114,7 +114,7 @@ def __handle_add_point(ctx, event): ctx.point_list_widget.update() -def __handle_edit_point(ctx, event): +def _handle_edit_point(ctx, event): # TODO: This function and delete definitely need to make sure they are # on a point we have. # @@ -127,7 +127,7 @@ def __handle_edit_point(ctx, event): # Should move the associated point in the list to the new location if # applicable. - __handle_info_updates(ctx, event) + _handle_info_updates(ctx, event) PointManager.point_set.clear_selection() # Store old x, y from event @@ -147,16 +147,16 @@ def ogl_keypress_handler(ctx, event): @param ctx A handle to the window context. @param event The event associated with this handler. """ - global __left_click_flag - global __last_mouse_pos + global _left_click_flag + global _last_mouse_pos if event.key() == Qt.Key_Escape: if ctx.mode is Mode.MOVE: - if __left_click_flag is not __ClickFlag.NONE: + if _left_click_flag is not _ClickFlag.NONE: - __last_mouse_pos = None + _last_mouse_pos = None - __left_click_flag = __ClickFlag.NONE + _left_click_flag = _ClickFlag.NONE PointManager.point_set.clear_selection() reset_move_bbs() refresh_point_list(ctx) @@ -171,7 +171,7 @@ def ogl_keypress_handler(ctx, event): ctx.opengl_widget.update() -def __handle_move_points(ctx, event): +def _handle_move_points(ctx, event): """ A relatively complicated state machine that handles the process of selection, clicking, and dragging. @@ -180,52 +180,52 @@ def __handle_move_points(ctx, event): @param event The event. """ - global __left_click_flag - global __left_mouse_down - global __last_mouse_pos + global _left_click_flag + global _left_mouse_down + global _last_mouse_pos set_drawing_event(event) - __handle_info_updates(ctx, event) + _handle_info_updates(ctx, event) # If we release the mouse, we want to quickly alert drag mode. if (event.button() == Qt.LeftButton and event.type() == QEvent.MouseButtonRelease): - __left_mouse_down = False + _left_mouse_down = False # This if statement block is used to set the bounding box for # drawing and call the selection procedure. if (event.button() == Qt.LeftButton and event.type() == QEvent.MouseButtonPress): - __left_mouse_down = True + _left_mouse_down = True - if __left_click_flag is __ClickFlag.NONE: - __left_click_flag = __ClickFlag.SELECTION_BOX + if _left_click_flag is _ClickFlag.NONE: + _left_click_flag = _ClickFlag.SELECTION_BOX set_move_bb_top_left(event.x(), event.y()) - elif (__left_click_flag is __ClickFlag.SELECTION_BOX - and __left_mouse_down): + elif (_left_click_flag is _ClickFlag.SELECTION_BOX + and _left_mouse_down): # We are now in the click-and-hold to signal move # tracking and translation - __left_click_flag = __ClickFlag.SELECTION_MOVE - __last_mouse_pos = (event.x(), event.y()) + _left_click_flag = _ClickFlag.SELECTION_MOVE + _last_mouse_pos = (event.x(), event.y()) # Post-selection handlers - if (__left_click_flag is __ClickFlag.SELECTION_BOX + if (_left_click_flag is _ClickFlag.SELECTION_BOX and event.type() == QEvent.MouseMove): set_move_bb_bottom_right(event.x(), event.y()) - elif (__left_click_flag is __ClickFlag.SELECTION_MOVE - and __last_mouse_pos is not None - and __left_mouse_down + elif (_left_click_flag is _ClickFlag.SELECTION_MOVE + and _last_mouse_pos is not None + and _left_mouse_down and event.type() == QEvent.MouseMove): - dx = abs(__last_mouse_pos[0] - event.x()) - dy = abs(__last_mouse_pos[1] - event.y()) + dx = abs(_last_mouse_pos[0] - event.x()) + dy = abs(_last_mouse_pos[1] - event.y()) for p in PointManager.point_set.points: if p.selected: @@ -235,13 +235,13 @@ def __handle_move_points(ctx, event): # fly off screen quickly as we got farther from our # start. try: - if event.x() < __last_mouse_pos[0]: + if event.x() < _last_mouse_pos[0]: p.move(-dx, 0) - if event.y() < __last_mouse_pos[1]: + if event.y() < _last_mouse_pos[1]: p.move(0, -dy) - if event.x() > __last_mouse_pos[0]: + if event.x() > _last_mouse_pos[0]: p.move(dx, 0) - if event.y() > __last_mouse_pos[1]: + if event.y() > _last_mouse_pos[1]: p.move(0, dy) except ExceededWindowBoundsError: @@ -250,12 +250,12 @@ def __handle_move_points(ctx, event): # point. continue - __last_mouse_pos = (event.x(), event.y()) + _last_mouse_pos = (event.x(), event.y()) - elif (__left_click_flag is not __ClickFlag.NONE and + elif (_left_click_flag is not _ClickFlag.NONE and event.type() == QEvent.MouseButtonRelease): - if __left_click_flag is __ClickFlag.SELECTION_BOX: + if _left_click_flag is _ClickFlag.SELECTION_BOX: set_move_bb_bottom_right(event.x(), event.y()) @@ -265,9 +265,9 @@ def __handle_move_points(ctx, event): ctx.opengl_widget.update() -def __handle_delete_point(ctx, event): +def _handle_delete_point(ctx, event): - __handle_info_updates(ctx, event) + _handle_info_updates(ctx, event) if (event.button() == Qt.LeftButton and event.type() == QEvent.MouseButtonPress): @@ -282,7 +282,7 @@ def __handle_delete_point(ctx, event): ctx.point_list_widget.update() -def __handle_info_updates(ctx, event): +def _handle_info_updates(ctx, event): """ Updates data under the "information" header. @@ -293,19 +293,19 @@ def __handle_info_updates(ctx, event): ctx.mouse_position_label.setText(f"{event.x(), event.y()}") -def __handle_choose_centroids(ctx, event): +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 - global __remaining_colors + global _centroid_count + global _remaining_colors - __handle_info_updates(ctx, event) + _handle_info_updates(ctx, event) - if __centroid_count == ctx.number_of_centroids.value(): + if _centroid_count == ctx.number_of_centroids.value(): # We have specified the number of centroids required return @@ -326,32 +326,32 @@ def __handle_choose_centroids(ctx, event): # Centroids must be unique return - __centroid_count += 1 + _centroid_count += 1 - color = random.choice(__remaining_colors) - __remaining_colors.remove(color) + 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(): + 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.group_button.setEnabled(True) + 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 + global _centroid_count + global _remaining_colors - __centroid_count = 0 - __remaining_colors = [c for c in Color if - c not in [Color.BLUE, Color.GREY]] + _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: point.color = Color.GREY @@ -392,11 +392,11 @@ def generate_random_points(point_count, x_bound, y_bound): # Simple dispatcher to make it easy to dispatch the right mode # function when the OpenGL window is acted on. MODE_HANDLER_MAP = { - Mode.OFF: __handle_info_updates, - Mode.LOADED: __handle_info_updates, - Mode.ADD: __handle_add_point, - Mode.EDIT: __handle_edit_point, - Mode.MOVE: __handle_move_points, - Mode.DELETE: __handle_delete_point, - Mode.CHOOSE_CENTROIDS: __handle_choose_centroids + Mode.OFF: _handle_info_updates, + Mode.LOADED: _handle_info_updates, + Mode.ADD: _handle_add_point, + Mode.EDIT: _handle_edit_point, + Mode.MOVE: _handle_move_points, + Mode.DELETE: _handle_delete_point, + Mode.CHOOSE_CENTROIDS: _handle_choose_centroids } diff --git a/clusterview2/ui/opengl_widget.py b/clusterview2/ui/opengl_widget.py index 36a9614..d52659e 100644 --- a/clusterview2/ui/opengl_widget.py +++ b/clusterview2/ui/opengl_widget.py @@ -43,6 +43,10 @@ __current_context = None __current_event = None +# TODO: This should live inside of a class as static methods with the +# globals moved into the static scope to make this nicer...once you +# get it running before doing kmeans make this modification. + def set_drawing_context(ctx): """ Sets the drawing context so that drawing functions can properly @@ -63,8 +67,8 @@ def set_drawing_event(event): global __current_event if __current_context is None: - raise InvalidStateError("Drawing context must be set before setting " + - "drawing mode") + raise InvalidStateError('Drawing context must be set before setting ' + + 'drawing mode') if event is not None: __current_event = event @@ -180,12 +184,13 @@ def paint_gl(): __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.GROUP): + __current_context.mode is Mode.UNWEIGHTED_CLUSTERING or + __current_context.mode is Mode.WEIGHTED_CLUSTERING): draw_points(PointManager.point_set) elif __current_context.mode is Mode.EDIT: - raise NotImplementedError("Drawing for EDIT not implemented.") + raise NotImplementedError('Drawing for EDIT not implemented.') elif __current_context.mode is Mode.MOVE: # We have to repeatedly draw the points while we are showing the @@ -298,11 +303,11 @@ def draw_selection_box(color): global __current_context if __current_context is None: - raise InvalidStateError("Drawing context must be set before setting " + - "drawing mode") + raise InvalidStateError('Drawing context must be set before setting ' + + 'drawing mode') if not isinstance(color, Color): - raise ValueError("Color must exist in the Color enumeration") + raise ValueError('Color must exist in the Color enumeration') if __move_bb_top_left is None or __move_bb_bottom_right is None: # Nothing to draw. @@ -367,8 +372,8 @@ def draw_points(point_set): global __current_context if __current_context is None: - raise InvalidStateError("Drawing context must be set before setting " + - "drawing mode") + raise InvalidStateError('Drawing context must be set before setting ' + + 'drawing mode') glViewport(0, 0, __WIDTH, __HEIGHT) glPointSize(PointManager.point_set.point_size) diff --git a/clusterview2/ui/point_list_widget.py b/clusterview2/ui/point_list_widget.py index 6df8574..12ff331 100644 --- a/clusterview2/ui/point_list_widget.py +++ b/clusterview2/ui/point_list_widget.py @@ -8,7 +8,7 @@ is defined in the clusterview_ui.py file. from clusterview2.point_manager import PointManager -def __string_point_to_point(str_point): +def _string_point_to_point(str_point): """ In the QListWidget points are stored as strings because of the way Qt has list items defined. @@ -39,7 +39,7 @@ def item_click_handler(ctx, item): @param ctx The context. @param item The clicked item. """ - point = __string_point_to_point(item.text()) + point = _string_point_to_point(item.text()) # TODO: Super slow linear search, should write a find_point function # on the point_set in order to speed this up since PointSet diff --git a/clusterview2_ui.py b/clusterview2_ui.py index 4fad721..a14fa48 100644 --- a/clusterview2_ui.py +++ b/clusterview2_ui.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file 'clusterview.ui' +# Form implementation generated from reading ui file 'clusterview2.ui' # # Created by: PyQt5 UI code generator 5.13.0 # @@ -78,17 +78,17 @@ class Ui_MainWindow(object): 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) - self.solve_button = QtWidgets.QPushButton(self.groupBox_3) - self.solve_button.setEnabled(False) - self.solve_button.setObjectName("solve_button") - self.formLayout.setWidget(5, QtWidgets.QFormLayout.LabelRole, self.solve_button) - self.group_button = QtWidgets.QPushButton(self.groupBox_3) - self.group_button.setEnabled(False) - self.group_button.setObjectName("group_button") - self.formLayout.setWidget(4, QtWidgets.QFormLayout.LabelRole, self.group_button) + 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.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.verticalLayout.addWidget(self.groupBox_3) spacerItem = QtWidgets.QSpacerItem(20, 20, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) self.verticalLayout.addItem(spacerItem) @@ -117,7 +117,7 @@ class Ui_MainWindow(object): self.horizontalLayout.addLayout(self.verticalLayout) MainWindow.setCentralWidget(self.centralwidget) self.menubar = QtWidgets.QMenuBar(MainWindow) - self.menubar.setGeometry(QtCore.QRect(0, 0, 1280, 28)) + self.menubar.setGeometry(QtCore.QRect(0, 0, 1280, 21)) self.menubar.setNativeMenuBar(True) self.menubar.setObjectName("menubar") self.menu_file = QtWidgets.QMenu(self.menubar) @@ -167,14 +167,14 @@ class Ui_MainWindow(object): def retranslateUi(self, MainWindow): _translate = QtCore.QCoreApplication.translate - MainWindow.setWindowTitle(_translate("MainWindow", "ClusterView")) + 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.solve_button.setText(_translate("MainWindow", "Solve")) - self.group_button.setText(_translate("MainWindow", "Group")) + 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.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 1d84087..4860a64 100644 --- a/main_window.py +++ b/main_window.py @@ -15,6 +15,7 @@ from clusterview2.ui.mode_handlers import (MODE_HANDLER_MAP, from clusterview2.ui.opengl_widget import (clear_selection, initialize_gl, mouse_leave, paint_gl, resize_gl, set_drawing_context) +from clusterview2.points import PointSet from clusterview2.point_manager import PointManager from clusterview2.ui.point_list_widget import item_click_handler from clusterview2_ui import Ui_MainWindow @@ -29,25 +30,25 @@ class MainWindow(QMainWindow, Ui_MainWindow): # This is a static mode variable since there will only ever # be one MainWindow. - __mode = Mode.OFF + _mode = Mode.OFF def __init__(self, parent=None): super(MainWindow, self).__init__(parent) self.setupUi(self) # Size of point for drawing - self.__point_size = 8 + self._point_size = 8 # TODO: THESE ARE HARD CODED TO THE CURRENT QT WIDGET SIZES # FIX THIS PROPERLY WITH A RESIZE EVENT DETECT. # PointManager is a class that is filled with static methods # designed for managing state. - self.__viewport_width = 833 - self.__viewport_height = 656 + self._viewport_width = 833 + self._viewport_height = 656 - PointManager.point_set = PointSet(self.__point_size, - self.__viewport_width, - self.__viewport_height) + PointManager.point_set = PointSet(self._point_size, + self._viewport_width, + self._viewport_height) # Spin box should only allow the number of centroids to be no # greater than the number of supported colors minus 2 to exclude @@ -82,11 +83,11 @@ 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.choose_centroids_button.clicked.connect(self._choose_centroids) - self.group_button.clicked.connect(self.__group) + self.unweighted_clustering_button.clicked.connect(self._unweighted_clustering) - self.reset_button.clicked.connect(self.__reset_grouping) + self.reset_button.clicked.connect(self._reset) # ----------------------------------------------- # OpenGL Graphics Handlers are set @@ -100,108 +101,108 @@ class MainWindow(QMainWindow, Ui_MainWindow): # ------------------------------------- # UI Handlers # ------------------------------------- - self.action_add_points.triggered.connect(self.__add_points) - self.action_edit_points.triggered.connect(self.__edit_points) - self.action_delete_points.triggered.connect(self.__delete_points) - self.action_move_points.triggered.connect(self.__move_points) + self.action_add_points.triggered.connect(self._add_points) + self.action_edit_points.triggered.connect(self._edit_points) + self.action_delete_points.triggered.connect(self._delete_points) + self.action_move_points.triggered.connect(self._move_points) (self.action_generate_random_points - .triggered.connect(self.__generate_random_points)) + .triggered.connect(self._generate_random_points)) self.action_save_point_configuration.triggered.connect( - self.__save_points_file) + self._save_points_file) self.action_load_point_configuration.triggered.connect( - self.__open_points_file) + self._open_points_file) - self.action_exit.triggered.connect(self.__close_event) + self.action_exit.triggered.connect(self._close_event) # Override handler for mouse press so we can draw points based on # the OpenGL coordinate system inside of the OpenGL Widget. - self.opengl_widget.mousePressEvent = self.__ogl_click_dispatcher - self.opengl_widget.mouseMoveEvent = self.__ogl_click_dispatcher - self.opengl_widget.mouseReleaseEvent = self.__ogl_click_dispatcher + self.opengl_widget.mousePressEvent = self._ogl_click_dispatcher + self.opengl_widget.mouseMoveEvent = self._ogl_click_dispatcher + self.opengl_widget.mouseReleaseEvent = self._ogl_click_dispatcher # ----------------------------------------------------------------- # Mode changers - these will be used to signal the action in the # OpenGL Widget. # ----------------------------------------------------------------- - def __off_mode(self): - self.__mode = Mode.OFF + def _off_mode(self): + self._mode = Mode.OFF self.opengl_widget.setCursor(QCursor(Qt.CursorShape.ArrowCursor)) - self.status_bar.showMessage("") + self.status_bar.showMessage('') clear_selection() self.opengl_widget.update() - def __add_points(self): - self.__mode = Mode.ADD + def _add_points(self): + self._mode = Mode.ADD self.opengl_widget.setCursor(QCursor(Qt.CursorShape.CrossCursor)) - self.status_bar.showMessage("ADD MODE") + self.status_bar.showMessage('ADD MODE') clear_selection() self.opengl_widget.update() - def __edit_points(self): - self.__mode = Mode.EDIT + def _edit_points(self): + self._mode = Mode.EDIT self.opengl_widget.setCursor(QCursor(Qt.CursorShape.CrossCursor)) - self.status_bar.showMessage("EDIT MODE") + self.status_bar.showMessage('EDIT MODE') clear_selection() self.opengl_widget.update() - def __delete_points(self): - self.__mode = Mode.DELETE + def _delete_points(self): + self._mode = Mode.DELETE self.opengl_widget.setCursor(QCursor( Qt.CursorShape.PointingHandCursor)) - self.status_bar.showMessage("DELETE MODE") + self.status_bar.showMessage('DELETE MODE') clear_selection() self.opengl_widget.update() - def __move_points(self): - self.__mode = Mode.MOVE + def _move_points(self): + self._mode = Mode.MOVE self.opengl_widget.setCursor(QCursor(Qt.CursorShape.SizeAllCursor)) - self.status_bar.showMessage("MOVE MODE - PRESS ESC OR SWITCH MODES" + - "TO CANCEL SELECTION") + self.status_bar.showMessage('MOVE MODE - PRESS ESC OR SWITCH MODES ' + + 'TO CANCEL SELECTION') clear_selection() self.opengl_widget.update() - def __choose_centroids(self): - self.__mode = Mode.CHOOSE_CENTROIDS + def _choose_centroids(self): + self._mode = Mode.CHOOSE_CENTROIDS self.opengl_widget.setCursor(QCursor(Qt.CursorShape.CrossCursor)) - self.status_bar.showMessage("CHOOSE CENTROIDS") + self.status_bar.showMessage('CHOOSE CENTROIDS') clear_selection() self.opengl_widget.update() - def __group(self): - self.__mode = Mode.GROUP + def _unweighted_clustering(self): + self._mode = Mode.UNWEIGHTED_CLUSTERING self.opengl_widget.setCursor(QCursor(Qt.CursorShape.ArrowCursor)) - self.status_bar.showMessage("GROUPING") + self.status_bar.showMessage('UNWEIGHTED CLUSTERING') clear_selection() - group(self) - self.__off_mode() + # unweighted_clustering(self) + self._off_mode() self.opengl_widget.update() - def __reset_grouping(self): - self.__off_mode() + 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.solve_button.setEnabled(False) - self.group_button.setEnabled(False) + self.unweighted_clustering_button.setEnabled(False) + self.weighted_clustering_button.setEnabled(False) PointManager.centroids = [] reset_centroid_count_and_colors() - def __generate_random_points(self): - value, ok = QInputDialog.getInt(self, "Number of Points", - "Number of Points:", 30, 30, 3000, 1) + def _generate_random_points(self): + value, ok = QInputDialog.getInt(self, 'Number of Points', + 'Number of Points:', 30, 30, 3000, 1) if ok: - self.__mode = Mode.ADD + self._mode = Mode.ADD generate_random_points(value, - (self.__viewport_width - self.__point_size), - (self.__viewport_height - self.__point_size) + (self._viewport_width - self._point_size), + (self._viewport_height - self._point_size) ) - self.__mode = Mode.OFF + self._mode = Mode.OFF self.opengl_widget.update() @@ -209,27 +210,27 @@ class MainWindow(QMainWindow, Ui_MainWindow): @property def mode(self): - """ + """" Function designed to be used from a context to get the current mode. """ - return self.__mode + return self._mode @mode.setter def mode(self, mode): - self.__mode = mode + self._mode = mode - def __close_event(self, event): + def _close_event(self, event): import sys sys.exit(0) - def __open_points_file(self): + def _open_points_file(self): ofile, _ = QFileDialog.getOpenFileName(self, - "Open Point Configuration", - "", - "JSON files (*.json)") + 'Open Point Configuration', + '', + 'JSON files (*.json)') if ofile: - self.__mode = Mode.LOADED + self._mode = Mode.LOADED PointManager.load(ofile) @@ -237,17 +238,17 @@ class MainWindow(QMainWindow, Ui_MainWindow): refresh_point_list(self) - def __save_points_file(self): + def _save_points_file(self): file_name, _ = (QFileDialog. getSaveFileName(self, - "Save Point Configuration", - "", - "JSON Files (*.json)")) + 'Save Point Configuration', + '', + 'JSON Files (*.json)')) if file_name: PointManager.save(file_name) @handle_exceptions - def __ogl_click_dispatcher(self, event): + def _ogl_click_dispatcher(self, event): """ Mode dispatcher for click actions on the OpenGL widget. """ @@ -256,4 +257,4 @@ class MainWindow(QMainWindow, Ui_MainWindow): # OpenGL event. The context passed to these functions allows # them to modify on screen widgets such as the QOpenGLWidget and # QListWidget. - MODE_HANDLER_MAP[self.__mode](self, event) + MODE_HANDLER_MAP[self._mode](self, event) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..6deafc2 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 120 From eb56f9af630f9d93b0c7a88c70fd8280a436f1cf Mon Sep 17 00:00:00 2001 From: Taylor Bockman Date: Wed, 16 Oct 2019 20:29:30 -0700 Subject: [PATCH 3/8] Make point inheirit from point in kmeans --- README.md | 10 +++- clusterview2/points.py | 155 +++++++++++++++++++++++++------------------------ requirements.txt | 2 +- 3 files changed, 88 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 8db6d64..4615357 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,15 @@ to explore k-means. ## Usage -TODO +First install the necessary packages: + +`pip install -r requirements.txt` + +Then launch clusterview2 using: + +`python clusterview2.py` + +from the root directory. ## Development diff --git a/clusterview2/points.py b/clusterview2/points.py index db933fb..bec91fd 100644 --- a/clusterview2/points.py +++ b/clusterview2/points.py @@ -1,11 +1,12 @@ from math import floor -from .colors import Color -from .exceptions import ExceededWindowBoundsError +from kmeans.clustering.point import Point as BasePoint -# TODO: THIS WILL NEED TO BE MODIFIED TO INHEIRIT THE KMEANS POINT CLASS. +from clusterview2.colors import Color +from clusterview2.exceptions import ExceededWindowBoundsError -class Point: + +class Point(BasePoint): """ A class representing a point. A point has a point_size bounding box around @@ -33,71 +34,71 @@ class Point: raise ValueError("Point must be initialized with a color of " + "type Color.") - self.__point_size = point_size - self.__x = x - self.__y = y + self._point_size = point_size + self._x = x + self._y = y - self.__color = color + self._color = color - self.__viewport_width = viewport_width - self.__viewport_height = viewport_height + self._viewport_width = viewport_width + self._viewport_height = viewport_height - self.__calculate_hitbox() + self._calculate_hitbox() - self.__check_window_bounds(x, y) + self._check_window_bounds(x, y) - self.__selected = False + self._selected = False - self.__attributes = [] + self._attributes = [] @property def x(self): - return self.__x + return self._x @property def y(self): - return self.__y + return self._y @property def point_size(self): - return self.__point_size + return self._point_size @property def selected(self): - return self.__selected + return self._selected @property def color(self): - return self.__color + return self._color @color.setter def color(self, color): if not isinstance(color, Color): raise ValueError('Point color must be of type Color.') - self.__color = color + self._color = color @property def attributes(self): - return self.__attributes + return self._attributes def add_attribute(self, attr): - self.__attributes.append(attr) + self._attributes.append(attr) - def __calculate_hitbox(self): + def _calculate_hitbox(self): """ Calculates the hit box for the point given the current position (center) and the point size. """ half_point = floor(self.point_size / 2.0) - self.__top_left_corner = (self.__x - half_point, - self.__y + half_point) + self._top_left_corner = (self._x - half_point, + self._y + half_point) - self.__bottom_right_corner = (self.__x + half_point, - self.__y - half_point) + self._bottom_right_corner = (self._x + half_point, + self._y - half_point) - def __check_window_bounds(self, x, y): + def _check_window_bounds(self, x, y): """ Simple window bound check that raises an exception when the point (x, y) exceeds the known viewport bounds. @@ -112,8 +113,8 @@ class Point: # We need to include the half point here because # the (x, y) for a point is the center of the square and we # do not want the EDGES to exceed the viewport bounds. - if (x > self.__viewport_width - half_point or - y > self.__viewport_height - half_point or + if (x > self._viewport_width - half_point or + y > self._viewport_height - half_point or x < half_point or y < half_point): @@ -127,14 +128,14 @@ class Point: @param dy The delta in the y direction. """ - self.__check_window_bounds(self.__x + dx, self.__y + dy) + self._check_window_bounds(self._x + dx, self._y + dy) - self.__x += dx - self.__y += dy + self._x += dx + self._y += dy # It's important to note as we move the point we need to # make sure we are constantly updating it's hitbox. - self.__calculate_hitbox() + self._calculate_hitbox() def __eq__(self, other): """ @@ -142,22 +143,22 @@ class Point: @param other The other object. """ - return (self.__x == other.x and - self.__y == other.y and - self.__color == other.color and - self.__attributes == other.attributes and - self.__point_size == other.point_size) + return (self._x == other.x and + self._y == other.y and + self._color == other.color and + self._attributes == other.attributes and + self._point_size == other.point_size) def __repr__(self): # For some reason I had to split this instead of using one giant # string chained with `+` inside of `()`. s = "= self.__top_left_corner[0] and - x <= self.__bottom_right_corner[0] and - y <= self.__top_left_corner[1] and - y >= self.__bottom_right_corner[1]) + return (x >= self._top_left_corner[0] and + x <= self._bottom_right_corner[0] and + y <= self._top_left_corner[1] and + y >= self._bottom_right_corner[1]) class Attribute: @@ -208,8 +209,8 @@ class Attribute: """ Initializes an attribute. """ - self.__name = name - self.__value = value + self._name = name + self._value = value class PointSet: @@ -230,29 +231,29 @@ class PointSet: @param viewport_height The height of the viewport for bounds calculations. """ - self.__points = [] - self.__point_size = point_size - self.__viewport_width = viewport_width - self.__viewport_height = viewport_height + self._points = [] + self._point_size = point_size + self._viewport_width = viewport_width + self._viewport_height = viewport_height def __eq__(self, other): other_points = list(other.points) - return (self.__points == other_points and - self.__point_size == other.point_size and - self.__viewport_width == other.viewport_width and - self.__viewport_height == other.viewport_height) + return (self._points == other_points and + self._point_size == other.point_size and + self._viewport_width == other.viewport_width and + self._viewport_height == other.viewport_height) def __repr__(self): s = [] - for p in self.__points: + for p in self._points: s.append(str(p)) return ",".join(s) def clear(self): - self.__points = [] + self._points = [] @property def points(self): @@ -260,37 +261,37 @@ class PointSet: Getter for points. Returns a generator for looping. """ - for point in self.__points: + for point in self._points: yield point @property def point_size(self): - return self.__point_size + return self._point_size @property def viewport_height(self): - return self.__viewport_height + return self._viewport_height @property def viewport_width(self): - return self.__viewport_width + return self._viewport_width @viewport_height.setter def viewport_height(self, height): - self.__viewport_height = height + self._viewport_height = height @viewport_width.setter def viewport_width(self, width): - self.__viewport_width = width + self._viewport_width = width def empty(self): - return len(self.__points) == 0 + return len(self._points) == 0 def clear_selection(self): """ Handy helper function to clear all selected points. """ - for p in self.__points: + for p in self._points: p.unselect() def add_point(self, x, y, color, attrs=[]): @@ -314,17 +315,17 @@ class PointSet: if not isinstance(color, Color): raise ValueError("Point color must be a Color enum.") - point = Point(x, y, color, self.__point_size, - self.__viewport_width, self.__viewport_height) + point = Point(x, y, color, self._point_size, + self._viewport_width, self._viewport_height) for attr in attrs: point.add_attribute(attr) - if point in self.__points: + if point in self._points: # Silently reject a duplicate point (same center). return - self.__points.append(point) + self._points.append(point) def remove_point(self, x, y): """ @@ -342,9 +343,9 @@ class PointSet: @param x The x-coordinate. @param y The y-coordinate. """ - for p in self.__points: + for p in self._points: if p.hit(x, y): - self.__points.remove(p) + self._points.remove(p) def groups(self): """ @@ -353,7 +354,7 @@ class PointSet: """ g = {} - for p in self.__points: + for p in self._points: if p.color not in g: # Create the key for the group color since it does # not exist. diff --git a/requirements.txt b/requirements.txt index 0a75791..6b29137 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,4 @@ PyOpenGL-accelerate==3.1.3b1 PyQt5==5.13.0 PyQt5-sip==4.19.18 -# Add the kmeans thing here. +-e git+https://git.xchg.sh/angrygoats/kmeans.git@master#egg=kmeans From adb1bfc0ce38b56263f3a59df083e527eeb925ff Mon Sep 17 00:00:00 2001 From: Taylor Bockman Date: Wed, 16 Oct 2019 21:53:12 -0700 Subject: [PATCH 4/8] Cut out old code, bolt on k-means --- clusterview2.ui | 26 ++++-------- clusterview2/debug.py | 9 ++++ clusterview2/mode.py | 5 +-- clusterview2/point_manager.py | 2 +- clusterview2/points.py | 20 ++++++++- clusterview2/ui/mode_handlers.py | 88 +++++++++++++--------------------------- clusterview2/ui/opengl_widget.py | 1 - clusterview2_ui.py | 33 +++++++-------- main_window.py | 41 ++++++++----------- 9 files changed, 99 insertions(+), 126 deletions(-) create mode 100644 clusterview2/debug.py 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): """" From 2ccb786d1fc2ec99c9101af3b270f24f8f13e1c5 Mon Sep 17 00:00:00 2001 From: Taylor Bockman Date: Thu, 17 Oct 2019 13:38:52 -0700 Subject: [PATCH 5/8] Add edit button for weights. --- clusterview2.ui | 14 ++---------- clusterview2/mode.py | 3 +-- clusterview2/points.py | 4 ++++ clusterview2/ui/mode_handlers.py | 46 +++++++++++++++++++++++++--------------- clusterview2/ui/opengl_widget.py | 7 ++---- clusterview2_ui.py | 17 ++++++--------- main_window.py | 16 ++++++++------ 7 files changed, 53 insertions(+), 54 deletions(-) diff --git a/clusterview2.ui b/clusterview2.ui index 1f1316a..ee96aff 100644 --- a/clusterview2.ui +++ b/clusterview2.ui @@ -130,26 +130,16 @@ - + false - Unweighted Clustering + K-Means Clustering - - - false - - - Weighted Clustering - - - - Reset diff --git a/clusterview2/mode.py b/clusterview2/mode.py index f7699d3..fe9a2f7 100644 --- a/clusterview2/mode.py +++ b/clusterview2/mode.py @@ -13,5 +13,4 @@ class Mode(Enum): MOVE = 3 DELETE = 4 LOADED = 5 - UNWEIGHTED_CLUSTERING = 6 - WEIGHTED_CLUSTERING = 7 + CLUSTERING = 6 diff --git a/clusterview2/points.py b/clusterview2/points.py index 5c75c38..ca61637 100644 --- a/clusterview2/points.py +++ b/clusterview2/points.py @@ -69,6 +69,10 @@ class Point(BasePoint): def weight(self): return self._weight + @weight.setter + def weight(self, weight): + return self._weight + @property def cluster(self): return self._cluster diff --git a/clusterview2/ui/mode_handlers.py b/clusterview2/ui/mode_handlers.py index 1ee1dd1..3240581 100644 --- a/clusterview2/ui/mode_handlers.py +++ b/clusterview2/ui/mode_handlers.py @@ -2,6 +2,7 @@ import random from PyQt5.QtCore import QEvent, Qt from PyQt5.QtGui import QCursor +from PyQt5.QtWidgets import QErrorMessage, QInputDialog from kmeans.algorithms import k_means @@ -117,25 +118,36 @@ def _handle_add_point(ctx, event): def _handle_edit_point(ctx, event): - # TODO: This function and delete definitely need to make sure they are - # on a point we have. - # - # Since points are unique consider a hashmap of points to make O(1) - # lookups for addition and deletion. This list can be maintained here - # in this module. It should be a dictionary - from point to - # attributes in the case of algorithms that require points to have - # weights or something. - # - # Should move the associated point in the list to the new location if - # applicable. - _handle_info_updates(ctx, event) PointManager.point_set.clear_selection() - # Store old x, y from event - set_drawing_event(event) - ctx.update() - # after this remove the point from the list + if (event.button() == Qt.LeftButton and + event.type() == QEvent.MouseButtonPress): + + # See if a point was hit + point = None + + for p in PointManager.point_set.points: + if p.hit(event.x(), event.y()): + point = p + break + + # Get point weight from user and assign it to the point. + if point is not None: + value, ok = QInputDialog.getDouble(None, 'Weight', 'Weight(Float): ', 1, 1, 3000, 1) + + if ok: + if not isinstance(value, float): + error_dialog = QErrorMessage() + error_dialog.showMessage('Point weight must be a floating point value.') + error_dialog.exec_() + + else: + point.weight = value + + # Store old x, y from event + set_drawing_event(event) + ctx.update() def ogl_keypress_handler(ctx, event): @@ -366,5 +378,5 @@ MODE_HANDLER_MAP = { Mode.EDIT: _handle_edit_point, Mode.MOVE: _handle_move_points, Mode.DELETE: _handle_delete_point, - Mode.UNWEIGHTED_CLUSTERING: _handle_clustering + Mode.CLUSTERING: _handle_clustering } diff --git a/clusterview2/ui/opengl_widget.py b/clusterview2/ui/opengl_widget.py index a53da3d..90fb655 100644 --- a/clusterview2/ui/opengl_widget.py +++ b/clusterview2/ui/opengl_widget.py @@ -182,15 +182,12 @@ def paint_gl(): if (__current_context.mode is Mode.ADD or __current_context.mode is Mode.DELETE or + __current_context.mode is Mode.EDIT or __current_context.mode is Mode.LOADED or - __current_context.mode is Mode.UNWEIGHTED_CLUSTERING or - __current_context.mode is Mode.WEIGHTED_CLUSTERING): + __current_context.mode is Mode.CLUSTERING): draw_points(PointManager.point_set) - elif __current_context.mode is Mode.EDIT: - raise NotImplementedError('Drawing for EDIT not implemented.') - elif __current_context.mode is Mode.MOVE: # We have to repeatedly draw the points while we are showing the # move box. diff --git a/clusterview2_ui.py b/clusterview2_ui.py index 5c3cb56..959a557 100644 --- a/clusterview2_ui.py +++ b/clusterview2_ui.py @@ -74,17 +74,13 @@ class Ui_MainWindow(object): 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(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(4, QtWidgets.QFormLayout.LabelRole, self.weighted_clustering_button) + self.clustering_button = QtWidgets.QPushButton(self.groupBox_3) + self.clustering_button.setEnabled(False) + self.clustering_button.setObjectName("clustering_button") + self.formLayout.setWidget(3, QtWidgets.QFormLayout.LabelRole, self.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.formLayout.setWidget(4, 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) @@ -167,8 +163,7 @@ class Ui_MainWindow(object): self.groupBox.setTitle(_translate("MainWindow", "Point List")) self.groupBox_3.setTitle(_translate("MainWindow", "Solver")) self.label_2.setText(_translate("MainWindow", "Clusters")) - self.unweighted_clustering_button.setText(_translate("MainWindow", "Unweighted Clustering")) - self.weighted_clustering_button.setText(_translate("MainWindow", "Weighted Clustering")) + self.clustering_button.setText(_translate("MainWindow", "K-Means Clustering")) self.reset_button.setText(_translate("MainWindow", "Reset")) self.groupBox_2.setTitle(_translate("MainWindow", "Canvas Information")) self.label.setText(_translate("MainWindow", "Mouse Position:")) diff --git a/main_window.py b/main_window.py index e4ae60e..9f2287b 100644 --- a/main_window.py +++ b/main_window.py @@ -83,7 +83,7 @@ class MainWindow(QMainWindow, Ui_MainWindow): self.point_list_widget.itemClicked.connect(partial(item_click_handler, self)) - self.unweighted_clustering_button.clicked.connect(self._unweighted_clustering) + self.clustering_button.clicked.connect(self._clustering) self.number_of_clusters.valueChanged.connect(self._clustering_enabled) self.reset_button.clicked.connect(self._reset) @@ -166,9 +166,9 @@ class MainWindow(QMainWindow, Ui_MainWindow): clear_selection() self.opengl_widget.update() - def _unweighted_clustering(self): + def _clustering(self): clear_selection() - self._mode = Mode.UNWEIGHTED_CLUSTERING + self._mode = Mode.CLUSTERING self.opengl_widget.setCursor(QCursor(Qt.CursorShape.ArrowCursor)) self.status_bar.showMessage('UNWEIGHTED CLUSTERING') self.opengl_widget.update() @@ -177,10 +177,13 @@ class MainWindow(QMainWindow, Ui_MainWindow): self._off_mode() self.number_of_clusters.setEnabled(True) self.number_of_clusters.setValue(0) - self.unweighted_clustering_button.setEnabled(False) - self.weighted_clustering_button.setEnabled(False) + self.clustering_button.setEnabled(False) self.clustering_solved = False PointManager.clusters = [] + + for point in PointManager.point_set.points: + point.weight = 1.0 + reset_colors() def _generate_random_points(self): @@ -200,8 +203,7 @@ 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) + self.clustering_button.setEnabled(self.number_of_clusters.value() > 0) @property def mode(self): From 9cff55d5fbb19cc855b4c14d712d931b60e0fb19 Mon Sep 17 00:00:00 2001 From: Taylor Bockman Date: Thu, 17 Oct 2019 13:42:17 -0700 Subject: [PATCH 6/8] Add error message for zero-point case. --- main_window.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/main_window.py b/main_window.py index 9f2287b..d8de27c 100644 --- a/main_window.py +++ b/main_window.py @@ -2,7 +2,7 @@ from functools import partial from PyQt5.QtCore import Qt from PyQt5.QtGui import QCursor -from PyQt5.QtWidgets import QFileDialog, QInputDialog, QMainWindow +from PyQt5.QtWidgets import QErrorMessage, QFileDialog, QInputDialog, QMainWindow from clusterview2.exceptions import handle_exceptions from clusterview2.colors import Color @@ -167,6 +167,12 @@ class MainWindow(QMainWindow, Ui_MainWindow): self.opengl_widget.update() def _clustering(self): + if len(list(PointManager.point_set.points)) == 0: + error_dialog = QErrorMessage() + error_dialog.showMessage('Place points before clustering.') + error_dialog.exec_() + return + clear_selection() self._mode = Mode.CLUSTERING self.opengl_widget.setCursor(QCursor(Qt.CursorShape.ArrowCursor)) From 79145ba8195d55a888fb03e652f1872939042a64 Mon Sep 17 00:00:00 2001 From: Taylor Bockman Date: Mon, 21 Oct 2019 21:00:41 -0700 Subject: [PATCH 7/8] Add point weight to saving and loading. --- clusterview2/point_manager.py | 5 +++-- clusterview2/points.py | 8 ++++++-- main_window.py | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/clusterview2/point_manager.py b/clusterview2/point_manager.py index 84931c0..056aa95 100644 --- a/clusterview2/point_manager.py +++ b/clusterview2/point_manager.py @@ -32,7 +32,7 @@ class PointManager(): # We will need to cast the string representation of color # back into a Color enum. PointManager.point_set.add_point(point['x'], point['y'], - Color(point['color'])) + Color(point['color'], point['weight'])) @staticmethod def save(location): @@ -52,7 +52,8 @@ class PointManager(): data['points'].append({ 'x': p.x, 'y': p.y, - 'color': p.color + 'color': p.color, + 'weight': p.weight }) with open(location, 'w') as out_file: diff --git a/clusterview2/points.py b/clusterview2/points.py index ca61637..8e68f40 100644 --- a/clusterview2/points.py +++ b/clusterview2/points.py @@ -316,7 +316,7 @@ class PointSet: for p in self._points: p.unselect() - def add_point(self, x, y, color, attrs=[]): + def add_point(self, x, y, color, weight=1.0, attrs=[]): """ Adds a point in screen coordinates and an optional attribute to the list. @@ -324,6 +324,7 @@ class PointSet: @param x The x-coordinate. @param y The y-coordinate. @param color The color of the point. + @param weight The point weight. @param attr An optional attribute. @raises ExceededWindowBoundsError If the point could not be constructed because it would be outside the @@ -337,8 +338,11 @@ class PointSet: if not isinstance(color, Color): raise ValueError("Point color must be a Color enum.") + if not isinstance(weight, float): + raise ValueError("Point weight must be a float.") + point = Point(x, y, color, self._point_size, - self._viewport_width, self._viewport_height) + self._viewport_width, self._viewport_height, weight) for attr in attrs: point.add_attribute(attr) diff --git a/main_window.py b/main_window.py index d8de27c..d641bf0 100644 --- a/main_window.py +++ b/main_window.py @@ -176,7 +176,7 @@ class MainWindow(QMainWindow, Ui_MainWindow): clear_selection() self._mode = Mode.CLUSTERING self.opengl_widget.setCursor(QCursor(Qt.CursorShape.ArrowCursor)) - self.status_bar.showMessage('UNWEIGHTED CLUSTERING') + self.status_bar.showMessage('CLUSTERING') self.opengl_widget.update() def _reset(self): From d871566e92c7cc56fb648efcf4e41da0eade5043 Mon Sep 17 00:00:00 2001 From: Taylor Bockman Date: Mon, 21 Oct 2019 21:12:01 -0700 Subject: [PATCH 8/8] Fix weight setting on points. --- clusterview2/point_manager.py | 2 +- clusterview2/points.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/clusterview2/point_manager.py b/clusterview2/point_manager.py index 056aa95..177a51d 100644 --- a/clusterview2/point_manager.py +++ b/clusterview2/point_manager.py @@ -32,7 +32,7 @@ class PointManager(): # We will need to cast the string representation of color # back into a Color enum. PointManager.point_set.add_point(point['x'], point['y'], - Color(point['color'], point['weight'])) + Color(point['color']), point['weight']) @staticmethod def save(location): diff --git a/clusterview2/points.py b/clusterview2/points.py index 8e68f40..86d7bfd 100644 --- a/clusterview2/points.py +++ b/clusterview2/points.py @@ -71,7 +71,7 @@ class Point(BasePoint): @weight.setter def weight(self, weight): - return self._weight + self._weight = weight @property def cluster(self): @@ -179,6 +179,7 @@ class Point(BasePoint): s += f"X: {self._x} | Y: {self._y} | " s += f"SIZE: {self._point_size} | " s += f"COLOR: {self._color} | " + s += f"WEIGHT: {self._weight} | " s += f"VIEWPORT_WIDTH: {self._viewport_width} | " s += f"VIEWPORT_HEIGHT: {self._viewport_height}" s += ">"