Browse Source

Porting over necessary clusterview components.

pull/1/head
Taylor Bockman 5 years ago
parent
commit
48e70df2bb
  1. 1
      .gitignore
  2. 13
      README.md
  3. 14
      TODO.org
  4. 18
      clusterview2.py
  5. 380
      clusterview2.ui
  6. 0
      clusterview2/__init__.py
  7. 27
      clusterview2/colors.py
  8. 53
      clusterview2/exceptions.py
  9. 17
      clusterview2/mode.py
  10. 59
      clusterview2/point_manager.py
  11. 0
      clusterview2/ui/__init__.py
  12. 402
      clusterview2/ui/mode_handlers.py
  13. 389
      clusterview2/ui/opengl_widget.py
  14. 53
      clusterview2/ui/point_list_widget.py
  15. 200
      clusterview2_ui.py
  16. 259
      main_window.py
  17. 8
      requirements-dev.txt
  18. 6
      requirements.txt

1
.gitignore vendored

@ -94,3 +94,4 @@ ENV/
# Rope project settings
.ropeproject
.mypy_cache

13
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.
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.

14
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.

18
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()

380
clusterview2.ui

@ -0,0 +1,380 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1280</width>
<height>720</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Maximum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>1280</width>
<height>720</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>1280</width>
<height>720</height>
</size>
</property>
<property name="windowTitle">
<string>ClusterView</string>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QOpenGLWidget" name="opengl_widget">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>900</width>
<height>16777215</height>
</size>
</property>
</widget>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QGroupBox" name="groupBox">
<property name="sizePolicy">
<sizepolicy hsizetype="Maximum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>100</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>200</width>
<height>200</height>
</size>
</property>
<property name="title">
<string>Point List</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QListWidget" name="point_list_widget">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>100</width>
<height>0</height>
</size>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_3">
<property name="title">
<string>Solver</string>
</property>
<layout class="QFormLayout" name="formLayout">
<item row="0" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Centroids</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QSpinBox" name="number_of_centroids">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>50</width>
<height>26</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>50</width>
<height>16777215</height>
</size>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QPushButton" name="choose_centroids_button">
<property name="enabled">
<bool>true</bool>
</property>
<property name="text">
<string>Choose Centroids</string>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QPushButton" name="solve_button">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>Solve</string>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QPushButton" name="group_button">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>Group</string>
</property>
</widget>
</item>
<item row="6" column="0">
<widget class="QPushButton" name="reset_button">
<property name="text">
<string>Reset</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QGroupBox" name="groupBox_2">
<property name="title">
<string>Canvas Information</string>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="0" column="3">
<widget class="QLabel" name="mouse_position_label">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>100</width>
<height>0</height>
</size>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="1" column="0">
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="0" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Mouse Position:</string>
</property>
</widget>
</item>
<item row="0" column="2">
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1280</width>
<height>28</height>
</rect>
</property>
<property name="nativeMenuBar">
<bool>true</bool>
</property>
<widget class="QMenu" name="menu_file">
<property name="title">
<string>File</string>
</property>
<addaction name="action_load_point_configuration"/>
<addaction name="action_save_point_configuration"/>
<addaction name="separator"/>
<addaction name="action_exit"/>
</widget>
<widget class="QMenu" name="menu_help">
<property name="title">
<string>Help</string>
</property>
</widget>
<addaction name="menu_file"/>
<addaction name="menu_help"/>
</widget>
<widget class="QStatusBar" name="status_bar"/>
<widget class="QToolBar" name="tool_bar">
<property name="windowTitle">
<string>toolBar</string>
</property>
<property name="movable">
<bool>false</bool>
</property>
<attribute name="toolBarArea">
<enum>LeftToolBarArea</enum>
</attribute>
<attribute name="toolBarBreak">
<bool>false</bool>
</attribute>
<addaction name="action_generate_random_points"/>
<addaction name="action_add_points"/>
<addaction name="action_move_points"/>
<addaction name="action_edit_points"/>
<addaction name="action_delete_points"/>
</widget>
<action name="action_add_points">
<property name="text">
<string>Add Points</string>
</property>
<property name="toolTip">
<string>Enables point adding mode.</string>
</property>
<property name="shortcut">
<string>Ctrl+A</string>
</property>
</action>
<action name="action_edit_points">
<property name="text">
<string>Edit Points</string>
</property>
<property name="toolTip">
<string>Enables point editing mode.</string>
</property>
<property name="shortcut">
<string>Ctrl+E</string>
</property>
</action>
<action name="action_delete_points">
<property name="text">
<string>Delete Points</string>
</property>
<property name="toolTip">
<string>Enables point deletion mode.</string>
</property>
<property name="shortcut">
<string>Ctrl+D</string>
</property>
</action>
<action name="action_solve">
<property name="text">
<string>Solve</string>
</property>
<property name="toolTip">
<string>Opens the solve dialog to choose a solving solution.</string>
</property>
<property name="shortcut">
<string>Ctrl+S</string>
</property>
</action>
<action name="action_move_points">
<property name="text">
<string>Move Points</string>
</property>
<property name="toolTip">
<string>Enables the movement of a selection of points.</string>
</property>
</action>
<action name="action_save_point_configuration">
<property name="text">
<string>Save Point Configuration</string>
</property>
</action>
<action name="action_load_point_configuration">
<property name="text">
<string>Load Point Configuration</string>
</property>
</action>
<action name="action_exit">
<property name="text">
<string>Exit</string>
</property>
</action>
<action name="action_generate_random_points">
<property name="text">
<string>Generate Random Points</string>
</property>
</action>
</widget>
<resources/>
<connections/>
</ui>

0
clusterview2/__init__.py

27
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)
}

53
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

17
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

59
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)

0
clusterview2/ui/__init__.py

402
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
}

389
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()

53
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()

200
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"))

259
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)

8
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

6
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.
Loading…
Cancel
Save