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.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

402 lines
12 KiB

import random
from PyQt5.QtCore import QEvent, Qt
from PyQt5.QtGui import QCursor
from clusterview2.colors import Color
from clusterview2.exceptions import ExceededWindowBoundsError
from clusterview2.mode import Mode
from .opengl_widget import (set_drawing_event, set_move_bb_top_left,
set_move_bb_bottom_right, reset_move_bbs,
viewport_height, viewport_width)
from clusterview2.point_manager import PointManager
class _ClickFlag:
# This is the first stage. On mouse release it goes to
# SELECTION_MOVE.
NONE = 0
# We are now in selection box mode.
SELECTION_BOX = 1
# Second stage - we have selected a number of points
# and now we are going to track the left mouse button
# to translate those points. After a left click
# this moves to SELECTED_MOVED.
SELECTION_MOVE = 2
# Any subsequent click in this mode will send it back
# to NONE - we are done.
SELECTED_MOVED = 3
# GLOBALS
# Canvas pixel border - empirical, not sure where this is stored officially
_CANVAS_BORDER = 1
# Module level flag for left click events (used to detect a left
# click hold drag)
_left_click_flag = _ClickFlag.NONE
# Variable to track the mouse state during selection movement
_last_mouse_pos = None
# Used to implement mouse dragging when clicked
_left_click_down = False
# TODO: WHEN THE GROUPING ENDS AND THE USER CANCELS THE CENTROID COUNT
# SHOULD BE ZEROED AND REMAINING COLORS SHOULD BE REPOPULATED.
# Count of centroids for comparison with the spin widget
_centroid_count = 0
_remaining_colors = [c for c in Color if c not in [Color.BLUE, Color.GREY]]
def refresh_point_list(ctx):
"""
Refreshes the point list display.
@param ctx A handle to the window context.
"""
# In order to make some guarantees and avoid duplicate
# data we will clear the point list widget and re-populate
# it using the current _point_set.
ctx.point_list_widget.clear()
for p in PointManager.point_set.points:
ctx.point_list_widget.addItem("({}, {})".format(p.x, p.y))
ctx.point_list_widget.update()
def _handle_add_point(ctx, event):
"""
Event handler for the add point mode.
Sets the drawing mode for the OpenGL Widget using
`set_drawing_mode`, converts a point to our point
representation, and adds it to the list.
@param ctx A context handle to the main window.
@param event The click event.
"""
# Update information as needed
_handle_info_updates(ctx, event)
if (event.button() == Qt.LeftButton and
event.type() == QEvent.MouseButtonPress):
# At this point we can be sure resize_gl has been called
# at least once, so set the viewport properties of the
# point set so it knows the canvas bounds.
PointManager.point_set.viewport_width = viewport_width()
PointManager.point_set.viewport_height = viewport_height()
# Clear any existing selections
PointManager.point_set.clear_selection()
try:
# No attribute at the moment, default point color is Color.GREY.
PointManager.point_set.add_point(event.x(), event.y(), Color.GREY)
except ExceededWindowBoundsError:
# The user tried to place a point whos edges would be
# on the outside of the window. We will just ignore it.
return
refresh_point_list(ctx)
set_drawing_event(event)
ctx.opengl_widget.update()
ctx.point_list_widget.update()
def _handle_edit_point(ctx, event):
# TODO: This function and delete definitely need to make sure they are
# on a point we have.
#
# Since points are unique consider a hashmap of points to make O(1)
# lookups for addition and deletion. This list can be maintained here
# in this module. It should be a dictionary - from point to
# attributes in the case of algorithms that require points to have
# weights or something.
#
# Should move the associated point in the list to the new location if
# applicable.
_handle_info_updates(ctx, event)
PointManager.point_set.clear_selection()
# Store old x, y from event
set_drawing_event(event)
ctx.update()
# after this remove the point from the list
def ogl_keypress_handler(ctx, event):
"""
A keypress handler attached to the OpenGL widget.
It primarily exists to allow the user to cancel selection.
Also allows users to escape from modes.
@param ctx A handle to the window context.
@param event The event associated with this handler.
"""
global _left_click_flag
global _last_mouse_pos
if event.key() == Qt.Key_Escape:
if ctx.mode is Mode.MOVE:
if _left_click_flag is not _ClickFlag.NONE:
_last_mouse_pos = None
_left_click_flag = _ClickFlag.NONE
PointManager.point_set.clear_selection()
reset_move_bbs()
refresh_point_list(ctx)
elif ctx.mode is not Mode.OFF:
ctx.mode = Mode.OFF
# Also change the mouse back to normal
ctx.opengl_widget.setCursor(QCursor(Qt.CursorShape.ArrowCursor))
ctx.status_bar.showMessage("")
ctx.opengl_widget.update()
def _handle_move_points(ctx, event):
"""
A relatively complicated state machine that handles the process of
selection, clicking, and dragging.
@param ctx The context to the window.
@param event The event.
"""
global _left_click_flag
global _left_mouse_down
global _last_mouse_pos
set_drawing_event(event)
_handle_info_updates(ctx, event)
# If we release the mouse, we want to quickly alert drag mode.
if (event.button() == Qt.LeftButton and
event.type() == QEvent.MouseButtonRelease):
_left_mouse_down = False
# This if statement block is used to set the bounding box for
# drawing and call the selection procedure.
if (event.button() == Qt.LeftButton and
event.type() == QEvent.MouseButtonPress):
_left_mouse_down = True
if _left_click_flag is _ClickFlag.NONE:
_left_click_flag = _ClickFlag.SELECTION_BOX
set_move_bb_top_left(event.x(), event.y())
elif (_left_click_flag is _ClickFlag.SELECTION_BOX
and _left_mouse_down):
# We are now in the click-and-hold to signal move
# tracking and translation
_left_click_flag = _ClickFlag.SELECTION_MOVE
_last_mouse_pos = (event.x(), event.y())
# Post-selection handlers
if (_left_click_flag is _ClickFlag.SELECTION_BOX
and event.type() == QEvent.MouseMove):
set_move_bb_bottom_right(event.x(), event.y())
elif (_left_click_flag is _ClickFlag.SELECTION_MOVE
and _last_mouse_pos is not None
and _left_mouse_down
and event.type() == QEvent.MouseMove):
dx = abs(_last_mouse_pos[0] - event.x())
dy = abs(_last_mouse_pos[1] - event.y())
for p in PointManager.point_set.points:
if p.selected:
# Use the deltas to decide what direction to move.
# We only want to move in small unit increments.
# If we used the deltas directly the points would
# fly off screen quickly as we got farther from our
# start.
try:
if event.x() < _last_mouse_pos[0]:
p.move(-dx, 0)
if event.y() < _last_mouse_pos[1]:
p.move(0, -dy)
if event.x() > _last_mouse_pos[0]:
p.move(dx, 0)
if event.y() > _last_mouse_pos[1]:
p.move(0, dy)
except ExceededWindowBoundsError:
# This point has indicated a move would exceed
# it's bounds, so we'll just go to the next
# point.
continue
_last_mouse_pos = (event.x(), event.y())
elif (_left_click_flag is not _ClickFlag.NONE and
event.type() == QEvent.MouseButtonRelease):
if _left_click_flag is _ClickFlag.SELECTION_BOX:
set_move_bb_bottom_right(event.x(), event.y())
# Satisfy the post condition by resetting the bounding box
reset_move_bbs()
ctx.opengl_widget.update()
def _handle_delete_point(ctx, event):
_handle_info_updates(ctx, event)
if (event.button() == Qt.LeftButton and
event.type() == QEvent.MouseButtonPress):
set_drawing_event(event)
PointManager.point_set.remove_point(event.x(), event.y())
refresh_point_list(ctx)
ctx.opengl_widget.update()
ctx.point_list_widget.update()
def _handle_info_updates(ctx, event):
"""
Updates data under the "information" header.
@param ctx The context to the main window.
@param event The event.
"""
if event.type() == QEvent.MouseMove:
ctx.mouse_position_label.setText(f"{event.x(), event.y()}")
def _handle_choose_centroids(ctx, event):
"""
Similar to move in terms of selecting points, however this
function assigns a random color up to the maximum number
of centroids, and after the maximum number has been selected it will
enable the group button.
"""
global _centroid_count
global _remaining_colors
_handle_info_updates(ctx, event)
if _centroid_count == ctx.number_of_centroids.value():
# We have specified the number of centroids required
return
if (event.button() == Qt.LeftButton and
event.type() == QEvent.MouseButtonPress):
point = None
for test_point in PointManager.point_set.points:
if test_point.hit(event.x(), event.y()):
point = test_point
if point is None:
# No point was found on the click, do nothing
return
if point in PointManager.centroids:
# Centroids must be unique
return
_centroid_count += 1
color = random.choice(_remaining_colors)
_remaining_colors.remove(color)
point.color = color
# Recolor the point and restash the point in centroids
PointManager.centroids.append(point)
if _centroid_count == ctx.number_of_centroids.value():
# Prevent the user from changing the centroids
ctx.number_of_centroids.setEnabled(False)
ctx.choose_centroids_button.setEnabled(False)
ctx.unweighted_clustering_button.setEnabled(True)
ctx.weighted_clustering_button.setEnabled(True)
ctx.opengl_widget.update()
def reset_centroid_count_and_colors():
global _centroid_count
global _remaining_colors
_centroid_count = 0
_remaining_colors = [c for c in Color if c not in [Color.BLUE, Color.GREY]]
for point in PointManager.point_set.points:
point.color = Color.GREY
def generate_random_points(point_count, x_bound, y_bound):
"""
Using the random module of python generate a unique set of xs and ys
to use as points, bounded by the canvas edges.
@param point_count The count of points to generate.
@param x_bound The width bound.
@param y_bound The height bound.
"""
# TODO: The window size should be increased slightly to
# accomodate 3000 points (the maximum) given the point size.
# Work out an algorithm and limit the number of points
# selectable based on the maximum amount of points on the screen
# given the point size.
# First clear the point set
PointManager.point_set.clear()
point_size = PointManager.point_set.point_size
# Sample without replacement so points are not duplicated.
xs = random.sample(range(point_size, x_bound), point_count)
ys = random.sample(range(point_size, y_bound), point_count)
points = list(zip(xs, ys))
for point in points:
PointManager.point_set.add_point(point[0], point[1], Color.GREY)
# Simple dispatcher to make it easy to dispatch the right mode
# function when the OpenGL window is acted on.
MODE_HANDLER_MAP = {
Mode.OFF: _handle_info_updates,
Mode.LOADED: _handle_info_updates,
Mode.ADD: _handle_add_point,
Mode.EDIT: _handle_edit_point,
Mode.MOVE: _handle_move_points,
Mode.DELETE: _handle_delete_point,
Mode.CHOOSE_CENTROIDS: _handle_choose_centroids
}