Beginning port work + k-means #1

Merged
angrygoats merged 8 commits from tb-port-work into master 5 years ago
  1. 1
      .gitignore
  2. 29
      README.md
  3. 17
      TODO.org
  4. 18
      clusterview2.py
  5. 360
      clusterview2.ui
  6. 0
      clusterview2/__init__.py
  7. 27
      clusterview2/colors.py
  8. 9
      clusterview2/debug.py
  9. 57
      clusterview2/exceptions.py
  10. 16
      clusterview2/mode.py
  11. 60
      clusterview2/point_manager.py
  12. 392
      clusterview2/points.py
  13. 0
      clusterview2/ui/__init__.py
  14. 382
      clusterview2/ui/mode_handlers.py
  15. 390
      clusterview2/ui/opengl_widget.py
  16. 53
      clusterview2/ui/point_list_widget.py
  17. 190
      clusterview2_ui.py
  18. 263
      main_window.py
  19. 8
      requirements-dev.txt
  20. 6
      requirements.txt
  21. 2
      setup.cfg

1
.gitignore vendored

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

29
README.md

@ -1,3 +1,30 @@
# clusterview2 # 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
First install the necessary packages:
`pip install -r requirements.txt`
Then launch clusterview2 using:
`python clusterview2.py`
from the root directory.
## 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.
### 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.

17
TODO.org

@ -0,0 +1,17 @@
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.
* 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.
* 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. 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)

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

360
clusterview2.ui

@ -0,0 +1,360 @@
<?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>ClusterView2</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>Clusters</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QSpinBox" name="number_of_clusters">
<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="clustering_button">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>K-Means Clustering</string>
</property>
</widget>
</item>
<item row="4" 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>21</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)
}

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

57
clusterview2/exceptions.py

@ -0,0 +1,57 @@
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

16
clusterview2/mode.py

@ -0,0 +1,16 @@
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
CLUSTERING = 6

60
clusterview2/point_manager.py

@ -0,0 +1,60 @@
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
clusters = []
@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']), point['weight'])
@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,
'weight': p.weight
})
with open(location, 'w') as out_file:
json.dump(data, out_file)

392
clusterview2/points.py

@ -0,0 +1,392 @@
from math import floor
from kmeans.clustering.point import Point as BasePoint
from clusterview2.colors import Color
from clusterview2.exceptions import ExceededWindowBoundsError
class Point(BasePoint):
"""
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, weight=1.0):
"""
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
# 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
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 weight(self):
return self._weight
@weight.setter
def weight(self, weight):
self._weight = 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
@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 = "<POINT "
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 += ">"
return s
def select(self):
"""
Selects the point.
"""
self._selected = True
def unselect(self):
"""
Unselects the point.
"""
self._selected = False
def hit(self, x, y):
"""
Determines if the point was hit inside of it's bounding box.
The condition for hit is simple - consider the following
bounding box:
-------------
| |
| (x,y) |
| |
-------------
Where the clicked location is in the center. Then the top
left corner is defined as (x - half_point_size, y + half_point_size)
and the bottom corner is (x + half_point_size, y - half_point_size)
So long as x and y are greater than the top left and less than the
top right it is considered a hit.
This function is necessary for properly deleting and selecting points.
"""
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:
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, weight=1.0, 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 weight The point weight.
@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.")
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, weight)
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

0
clusterview2/ui/__init__.py

382
clusterview2/ui/mode_handlers.py

@ -0,0 +1,382 @@
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
from clusterview2.colors import Color
from clusterview2.exceptions import ExceededWindowBoundsError
from clusterview2.mode import Mode
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
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):
_handle_info_updates(ctx, event)
PointManager.point_set.clear_selection()
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):
"""
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 reset_colors():
global _remaining_colors
_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)
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 = {
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.CLUSTERING: _handle_clustering
}

390
clusterview2/ui/opengl_widget.py

@ -0,0 +1,390 @@
"""
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
# 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
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.EDIT or
__current_context.mode is Mode.LOADED or
__current_context.mode is Mode.CLUSTERING):
draw_points(PointManager.point_set)
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()

190
clusterview2_ui.py

@ -0,0 +1,190 @@
# -*- coding: utf-8 -*-
# Form implementation generated from reading ui file 'clusterview2.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_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_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.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(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)
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, 21))
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", "ClusterView2"))
self.groupBox.setTitle(_translate("MainWindow", "Point List"))
self.groupBox_3.setTitle(_translate("MainWindow", "Solver"))
self.label_2.setText(_translate("MainWindow", "Clusters"))
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:"))
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"))

263
main_window.py

@ -0,0 +1,263 @@
from functools import partial
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QCursor
from PyQt5.QtWidgets import QErrorMessage, 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_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.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
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 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_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.
# 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.clustering_button.clicked.connect(self._clustering)
self.number_of_clusters.valueChanged.connect(self._clustering_enabled)
self.reset_button.clicked.connect(self._reset)
# -----------------------------------------------
# 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
# 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.
# -----------------------------------------------------------------
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 _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))
self.status_bar.showMessage('CLUSTERING')
self.opengl_widget.update()
def _reset(self):
self._off_mode()
self.number_of_clusters.setEnabled(True)
self.number_of_clusters.setValue(0)
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):
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)
def _clustering_enabled(self):
self.clustering_button.setEnabled(self.number_of_clusters.value() > 0)
@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
-e git+https://git.xchg.sh/angrygoats/kmeans.git@master#egg=kmeans

2
setup.cfg

@ -0,0 +1,2 @@
[flake8]
max-line-length = 120
Loading…
Cancel
Save