Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RectangleSelectionTool #162

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions chaco/tools/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from .range_selection import RangeSelection
from .range_selection_2d import RangeSelection2D
from .range_selection_overlay import RangeSelectionOverlay
from .rectangle_selection import RectangleSelection
from .rectangular_selection import RectangularSelection
from .regression_lasso import RegressionLasso, RegressionOverlay
from .save_tool import SaveTool
Expand Down
308 changes: 308 additions & 0 deletions chaco/tools/rectangle_selection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
""" Defines the RectangleSelectionTool class.
"""
# Major library imports
import numpy

# Enthought library imports
from traits.api import Bool, Enum, Trait, Int, Float, Tuple, Array
from enable.api import ColorTrait, KeySpec
from chaco.abstract_overlay import AbstractOverlay


class RectangleSelection(AbstractOverlay):
""" Selection tool which allows the user to draw a box which defines a
selected region and draw an overlay for the selected region.
"""

#: Is the tool always "on"? If True, left-clicking always initiates a
#: selection operation; if False, the user must press a key to enter
#: selection mode.
always_on = Bool(False)

#: Defines a meta-key, that works with always_on to set the selection mode.
#: This is useful when the selection tool is used in conjunction with the
#: pan tool.
always_on_modifier = Enum('control', 'shift', 'control', 'alt')

#: The minimum amount of screen space the user must select in order for
#: the tool to actually take effect.
minimum_screen_delta = Int(10)

#: Bounding box of selected region: (xmin, xmax, ymin, ymax)
#TODO: Rename to `selection`?
selected_box = Tuple()

#: Key press to clear selected box
clear_selected_key = KeySpec('Esc')

#-------------------------------------------------------------------------
# Appearance properties (for Box mode)
#-------------------------------------------------------------------------

#: The pointer to use when drawing a selection box.
pointer = "magnifier"

#: The color of the selection box.
color = ColorTrait("lightskyblue")

#: The alpha value to apply to **color** when filling in the selection
#: region. Because it is almost certainly useless to have an opaque
#: selection rectangle, but it's also extremely useful to be able to use
#: the normal named colors from Enable, this attribute allows the
#: specification of a separate alpha value that replaces the alpha value
#: of **color** at draw time.
alpha = Trait(0.4, None, Float)

#: The color of the outside selection rectangle.
border_color = ColorTrait("dodgerblue")

#: The thickness of selection rectangle border.
border_size = Int(1)

#: The possible event states of this selection tool.
# normal:
# Nothing has been selected, and the user is not dragging the mouse.
# selecting:
# The user is dragging the mouse and actively changing the
# selection region; resizing of an existing selection also
# uses this mode.
# moving:
# The user moving (not resizing) the selection range.
event_state = Enum("normal", "selecting", "moving")

#: The (x, y) screen point where the mouse went down.
_screen_start = Trait(None, None, Tuple)

#: The (x, y) screen point of the last seen mouse move event.
_screen_end = Trait(None, None, Tuple)

#: If **always_on** is False, this attribute indicates whether the tool
#: is currently enabled.
_enabled = Bool(False)

#------------------------------------------------------------------------
# Moving (not resizing) state
#------------------------------------------------------------------------

#: The position of the initial user click for moving the selection.
_move_start = Array # (x, y)

#: Move distance during moving state.
_move_offset = Array(value=(0, 0)) # (x, )

def reset(self, event=None):
""" Resets the tool to normal state.
"""
self.event_state = "normal"

def clear_selected_box(self):
self._screen_start = self._screen_end = None
self.selected_box = ()

#--------------------------------------------------------------------------
# Interactor interface
#--------------------------------------------------------------------------

def normal_key_pressed(self, event):
""" Handles a key being pressed when the tool is in the 'normal'
state.
"""
if self.clear_selected_key.match(event) and not self._enabled:
self.clear_selected_box()
self.request_redraw()
event.handled = True

def normal_mouse_move(self, event):
self.position = (event.x, event.y)
event.handled = True

def normal_mouse_enter(self, event):
""" Try to set the focus to the window when the mouse enters, otherwise
the keypress events will not be triggered.
"""
if self.component._window is not None:
self.component._window._set_focus()

def normal_left_down(self, event):
""" Handles the left mouse button being pressed while the tool is in
the 'normal' state.

If the tool is enabled or always on, it starts selecting.
"""

if self._within_selected_box(event):
self._start_move(event)
else:
self._start_select(event)

event.handled = True

def selecting_mouse_move(self, event):
""" Handles the mouse moving when the tool is in the 'selecting' state.

The selection is extended to the current mouse position.
"""
self._screen_end = (event.x, event.y)
self.component.request_redraw()
event.handled = True

def selecting_left_up(self, event):
""" Handles the left mouse button being released when the tool is in
the 'selecting' state.

Finishes selecting and does the selection.
"""
self._end_select(event)

def moving_mouse_move(self, event):
""" Handles the mouse moving when the tool is in the 'moving' state.

Moves the overlay by an amount corresponding to the amount that the
mouse has moved since its button was pressed. If the new selection
range overlaps the endpoints of the data, it is truncated to that
endpoint.
"""
self._move_offset = (event.x, event.y) - self._move_start
self.component.request_redraw()
event.handled = True

def moving_left_up(self, event):
""" Handles the left mouse button coming up when the tool is in the
'moving' state.

Switches the tool to the 'selected' state.
"""
#TODO: Why are _screen_start and _screen_end tuples not arrays?
self._screen_start = tuple(self._screen_start + self._move_offset)
self._screen_end = tuple(self._screen_end + self._move_offset)

self._update_selected_box()
# Clear move
self._move_offset = (0, 0)

self.event_state = "normal"
event.handled = True

#--------------------------------------------------------------------------
# AbstractOverlay interface
#--------------------------------------------------------------------------

def overlay(self, component, gc, view_bounds=None, mode="normal"):
""" Draws this component overlaid on another component.

Overrides AbstractOverlay.
"""
self._overlay_box(component, gc)

#--------------------------------------------------------------------------
# private interface
#--------------------------------------------------------------------------

def _start_select(self, event):
""" Starts selecting the selection region
"""
self.event_state = "selecting"
self._screen_start = (event.x, event.y)
self._screen_end = None
event.window.set_pointer(self.pointer)
event.window.set_mouse_owner(self, event.net_transform())
self.selecting_mouse_move(event)

def _end_select(self, event):
""" Ends selection of the selection region, adds the new selection
range to the selection stack, and does the selection.
"""
self._screen_end = (event.x, event.y)
self._update_selected_box()
self._end_selecting(event)
event.handled = True

def _end_selecting(self, event=None):
""" Ends selection of selection region, without selectioning.
"""
self.reset()
self._enabled = False
if self.component.active_tool == self:
self.component.active_tool = None
if event and event.window:
event.window.set_pointer("arrow")

self.component.request_redraw()
if event and event.window.mouse_owner == self:
event.window.set_mouse_owner(None)

def _update_selected_box(self):
start = numpy.array(self._screen_start)
end = numpy.array(self._screen_end)

# If selection is below minimum, clear selected box.
if sum(abs(end - start)) < self.minimum_screen_delta:
self.clear_selected_box()
else:
# Selected box in screen coordinates
self._selected_box_screen = (sorted([start[0], end[0]]) +
sorted([start[1], end[1]]))
# Selected box in data coordinates
low, high = self._map_coordinate_box(self._screen_start,
self._screen_end)
self.selected_box = (low[0], high[0], low[1], high[1])

def _start_move(self, event):
self.event_state = "moving"
self._move_start = (event.x, event.y)
self.moving_mouse_move(event)

def _overlay_box(self, component, gc):
""" Draws the overlay as a box.
"""
if self._screen_start and self._screen_end:
with gc:
gc.set_antialias(0)
gc.set_line_width(self.border_size)
gc.set_stroke_color(self.border_color_)
gc.clip_to_rect(component.x, component.y,
component.width, component.height)
x, y = self._screen_start + self._move_offset
x2, y2 = self._screen_end + self._move_offset
rect = (x, y, x2 - x + 1, y2 - y + 1)
if self.color != "transparent":
if self.alpha:
color = list(self.color_)
if len(color) == 4:
color[3] = self.alpha
else:
color += [self.alpha]
else:
color = self.color_
gc.set_fill_color(color)
gc.draw_rect(rect)
else:
gc.rect(*rect)
gc.stroke_path()

def _map_coordinate_box(self, start, end):
""" Given start and end points in screen space, returns corresponding
low and high points in data space.
"""
low = [0, 0]
high = [0, 0]
for axis_index, mapper in [(0, self.component.x_mapper),
(1, self.component.y_mapper)]:
# Ignore missing axis mappers (ColorBar instances only have one).
if not mapper:
continue
low_val, high_val = sorted(mapper.map_data(point[axis_index])
for point in (start, end))
low[axis_index] = low_val
high[axis_index] = high_val
return low, high

def _within_selected_box(self, event):
if not self.selected_box:
return False

xmin, xmax, ymin, ymax = self._selected_box_screen
if xmin < event.x < xmax and ymin < event.y < ymax:
return True
else:
return False
82 changes: 82 additions & 0 deletions examples/demo/basic/rectangle_selection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from chaco.api import (ArrayPlotData, HPlotContainer, Plot)
from chaco.tools.api import RectangleSelection
from enable.api import ComponentEditor
from scipy.misc import face
from traits.api import (Any, Array, cached_property, HasTraits, Instance,
Property)
from traitsui.api import View, Item


class RectSelectionDemo(HasTraits):
container = Instance(HPlotContainer)
img = Array()
img_plot = Instance(Plot)
plot_data = Instance(ArrayPlotData())
selection = Any()
zoom_img = Property(Array, depends_on=['img', 'selection'])
zoom_plot = Instance(Plot)

def _container_default(self):
img_plot = self.img_plot
zoom_plot = self.zoom_plot
container = HPlotContainer(img_plot, zoom_plot)
return container

@cached_property
def _get_zoom_img(self):
return self.img

def _img_default(self):
return face()

def _img_plot_default(self):
img_plot = Plot(self.plot_data)
img_plot.img_plot("img",
origin="top left")
rst = RectangleSelection(img_plot)
img_plot.overlays.append(rst)
self.selection = rst
rst.on_trait_change(self.update_zoom, 'event_state')
return img_plot

def _plot_data_default(self):
plot_data = ArrayPlotData(img=self.img,
zoom_img=self.zoom_img)
return plot_data

def update_zoom(self):
# selected box is in data space coordinates
# by default data space is defined in number of array elements
box = self.selection.selected_box
height = self.img.shape[0]
width = self.img.shape[1]
if box is not ():
# plot is displayed with the y-axis inverted, so we need to map
# the 0-axis coordinates
bottom = max(0, height - int(box[3]))
top = min(height, height - int(box[2]))
left = max(0, int(box[0]))
right = min(width, int(box[1]))
self.plot_data.set_data("zoom_img",
self.img[bottom:top, left:right])
self.zoom_plot.plot_components[0].index.set_data((left, right),
(bottom, top))

def _zoom_plot_default(self):
zoom_plot = Plot(self.plot_data)
zoom_plot.img_plot("zoom_img",
origin="top left",)
return zoom_plot

default_traits_view = View(
Item('container',
editor=ComponentEditor(),
show_label=False,
width=800,),
resizable=True,
)


if __name__ == '__main__':
rsd = RectSelectionDemo()
rsd.configure_traits()