21 changed files with 2279 additions and 1 deletions
@ -1,3 +1,30 @@
|
||||
# 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. |
||||
|
@ -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) |
@ -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() |
@ -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,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) |
||||
} |
@ -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() |
@ -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 |
@ -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 |
@ -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) |
@ -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,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 |
||||
} |
@ -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() |
@ -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() |
@ -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")) |
@ -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) |
@ -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 |
@ -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 |
Loading…
Reference in new issue