Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
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
3 changes: 2 additions & 1 deletion fastplotlib/graphics/_features/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
TextOutlineThickness,
)

from ._selection_features import LinearSelectionFeature, LinearRegionSelectionFeature
from ._selection_features import LinearSelectionFeature, LinearRegionSelectionFeature, RectangleSelectionFeature
from ._common import Name, Offset, Rotation, Visible, Deleted


Expand All @@ -56,6 +56,7 @@
"TextOutlineThickness",
"LinearSelectionFeature",
"LinearRegionSelectionFeature",
"RectangleRegionSelectionFeature"
"Name",
"Offset",
"Rotation",
Expand Down
167 changes: 166 additions & 1 deletion fastplotlib/graphics/_features/_selection_features.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Sequence
from typing import Sequence, Tuple

import numpy as np

Expand Down Expand Up @@ -190,3 +190,168 @@ def set_value(self, selector, value: Sequence[float]):
# TODO: user's selector event handlers can call event.graphic.get_selected_indices() to get the data index,
# and event.graphic.get_selected_data() to get the data under the selection
# this is probably a good idea so that the data isn't sliced until it's actually necessary


class RectangleSelectionFeature(GraphicFeature):
"""
**additional event attributes:**

+----------------------+----------+------------------------------------+
| attribute | type | description |
+======================+==========+====================================+
| get_selected_indices | callable | returns indices under the selector |
+----------------------+----------+------------------------------------+
| get_selected_data | callable | returns data under the selector |
+----------------------+----------+------------------------------------+

**info dict:**

+----------+------------+-------------------------------------------+
| dict key | value type | value description |
+==========+============+===========================================+
| value | np.ndarray | new [xmin, xmax, ymin, ymax] of selection |
+----------+------------+-------------------------------------------+

"""
def __init__(
self,
value: tuple[float, float, float, float],
axis: str | None,
limits: tuple[float, float, float, float]
):
super().__init__()

self._axis = axis
self._limits = limits
self._value = tuple(int(v) for v in value)

@property
def value(self) -> np.ndarray[float]:
"""
(xmin, xmax, ymin, ymax) of the selection, in data space
"""
return self._value

@property
def axis(self) -> str:
"""one of "x" | "y" """
return self._axis
Comment thread
clewis7 marked this conversation as resolved.
Outdated

def set_value(self, selector, value: Sequence[float]):
"""
Set the selection of the rectangle selector.

Parameters
----------
selector: RectangleSelector

value: (float, float, float, float)
new values (xmin, xmax, ymin, ymax) of the selection
"""
if not len(value) == 4:
raise TypeError(
"Selection must be an array, tuple, list, or sequence in the form of `(xmin, xmax, ymin, ymax)`, "
"where `xmin`, `xmax`, `ymin`, `ymax` are numeric values."
)

# convert to array
value = np.asarray(value, dtype=np.float32)

# check for fixed axis
if self.axis == "x":
value[2] = self.value[2]
value[3] = self.value[3]
elif self.axis == "y":
value[1] = self.value[1]
value[0] = self.value[0]

# clip values if they are beyond the limits
value[:2] = value[:2].clip(self._limits[0], self._limits[1])
# clip y
value[2:] = value[2:].clip(self._limits[2], self._limits[3])

xmin, xmax, ymin, ymax = value

# make sure `selector width >= 2` and selector height >=2 , left edge must not move past right edge!
# or bottom edge must not move past top edge!
if not (xmax - xmin) >= 0 or not (ymax - ymin) >= 0:
return

# change fill mesh
# change left x position of the fill mesh
selector.fill.geometry.positions.data[mesh_masks.x_left] = xmin

# change right x position of the fill mesh
selector.fill.geometry.positions.data[mesh_masks.x_right] = xmax

# change bottom y position of the fill mesh
selector.fill.geometry.positions.data[mesh_masks.y_bottom] = ymin

# change top position of the fill mesh
selector.fill.geometry.positions.data[mesh_masks.y_top] = ymax

# change the edge lines

# each edge line is defined by two end points which are stored in the
# geometry.positions
# [x0, y0, z0]
# [x1, y1, z0]

# left line
z = selector.edges[0].geometry.positions.data[:, -1][0]
selector.edges[0].geometry.positions.data[:] = np.array(
[[xmin, ymin, z], [xmin, ymax, z]]
)

# right line
selector.edges[1].geometry.positions.data[:] = np.array(
[[xmax, ymin, z], [xmax, ymax, z]]
)

# bottom line
selector.edges[2].geometry.positions.data[:] = np.array(
[[xmin, ymin, z], [xmax, ymin, z]]
)

# top line
selector.edges[3].geometry.positions.data[:] = np.array(
[[xmin, ymax, z], [xmax, ymax, z]]
)


# change the vertice positions
Comment thread
clewis7 marked this conversation as resolved.
Outdated

# bottom left
selector.vertices[0].geometry.positions.data[:] = np.array([[xmin, ymin, 1]])

# bottom right
selector.vertices[1].geometry.positions.data[:] = np.array([[xmax, ymin, 1]])

# top left
selector.vertices[2].geometry.positions.data[:] = np.array([[xmin, ymax, 1]])

# top right
selector.vertices[3].geometry.positions.data[:] = np.array([[xmax, ymax, 1]])

self._value = value
#
# send changes to GPU
selector.fill.geometry.positions.update_range()
#
for edge in selector.edges:
edge.geometry.positions.update_range()

for vertex in selector.vertices:
vertex.geometry.positions.update_range()

# send event
if len(self._event_handlers) < 1:
return

event = FeatureEvent("selection", {"value": self.value})

event.get_selected_indices = selector.get_selected_indices
event.get_selected_data = selector.get_selected_data

# calls any events
self._call_event_handlers(event)
46 changes: 45 additions & 1 deletion fastplotlib/graphics/image.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import math
from typing import *

import pygfx

from ..utils import quick_min_max
from ._base import Graphic
from .selectors import LinearSelector, LinearRegionSelector
from .selectors import LinearSelector, LinearRegionSelector, RectangleSelector
from ._features import (
TextureArray,
ImageCmap,
Expand Down Expand Up @@ -393,3 +394,46 @@ def add_linear_region_selector(
selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1)

return selector

def add_rectangle_selector(
self,
selection: tuple[float, float, float, float] = None,
axis: str = None,
fill_color=(0, 0, 0.35, 0.2),
**kwargs
) -> RectangleSelector:
"""
Add a :class:`.RectangleSelector`. Selectors are just ``Graphic`` objects, so you can manage,
remove, or delete them from a plot area just like any other ``Graphic``.

Parameters
----------
selection: (float, float, float, float), optional
initial (xmin, xmax, ymin, ymax) of the selection
axis: str, default None
Optional string to restrict the movement of the selector along one axis. If passed, should
be one of "x" or "y".
fill_color: (float, float, float), optional
The fill color of the selector.
Comment thread
clewis7 marked this conversation as resolved.
Outdated
"""
# default selection is 25% of the diagonal
if selection is None:
diagonal = math.sqrt(self._data.value.shape[0] ** 2 + self._data.value.shape[1] ** 2)

selection = (0, int(diagonal / 4), 0, int(diagonal / 4))

# min/max limits are image shape
limits = (0, self._data.value.shape[0], 0, self._data.value.shape[1])

selector = RectangleSelector(
selection=selection,
limits=limits,
axis=axis,
fill_color=fill_color,
Comment thread
kushalkolar marked this conversation as resolved.
parent=self,
**kwargs
)

self._plot_area.add_graphic(selector, center=False)

return selector
90 changes: 72 additions & 18 deletions fastplotlib/graphics/line.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,24 @@
import pygfx

from ._positions_base import PositionsGraphic
from .selectors import LinearRegionSelector, LinearSelector
from .selectors import LinearRegionSelector, LinearSelector, RectangleSelector
from ._features import Thickness


class LineGraphic(PositionsGraphic):
_features = {"data", "colors", "cmap", "thickness"}

def __init__(
self,
data: Any,
thickness: float = 2.0,
colors: str | np.ndarray | Iterable = "w",
uniform_color: bool = False,
alpha: float = 1.0,
cmap: str = None,
cmap_transform: np.ndarray | Iterable = None,
isolated_buffer: bool = True,
**kwargs,
self,
data: Any,
thickness: float = 2.0,
colors: str | np.ndarray | Iterable = "w",
uniform_color: bool = False,
alpha: float = 1.0,
cmap: str = None,
cmap_transform: np.ndarray | Iterable = None,
isolated_buffer: bool = True,
**kwargs,
):
"""
Create a line Graphic, 2d or 3d
Expand Down Expand Up @@ -106,7 +106,7 @@ def thickness(self, value: float):
self._thickness.set_value(self, value)

def add_linear_selector(
self, selection: float = None, padding: float = 0.0, axis: str = "x", **kwargs
self, selection: float = None, padding: float = 0.0, axis: str = "x", **kwargs
) -> LinearSelector:
"""
Adds a linear selector.
Expand Down Expand Up @@ -158,11 +158,11 @@ def add_linear_selector(
return selector

def add_linear_region_selector(
self,
selection: tuple[float, float] = None,
padding: float = 0.0,
axis: str = "x",
**kwargs,
self,
selection: tuple[float, float] = None,
padding: float = 0.0,
axis: str = "x",
**kwargs,
) -> LinearRegionSelector:
"""
Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage,
Expand Down Expand Up @@ -216,9 +216,63 @@ def add_linear_region_selector(
# so we should only work with a proxy on the user-end
return selector

def add_rectangle_selector(
self,
selection: tuple[float, float, float, float] = None,
axis: str = None,
fill_color=(0, 0, 0.35, 0.2),
**kwargs
) -> RectangleSelector:
"""
Add a :class:`.RectangleSelector`. Selectors are just ``Graphic`` objects, so you can manage,
remove, or delete them from a plot area just like any other ``Graphic``.

Parameters
----------
selection: (float, float, float, float), optional
initial (xmin, xmax, ymin, ymax) of the selection
axis: str, default None
Optional string to restrict the movement of the selector along one axis. If passed, should
be one of "x" or "y".
fill_color: (float, float, float), optional
The fill color of the selector.
"""
# computes args to create selectors
n_datapoints = self.data.value.shape[0]
value_25p = int(n_datapoints / 4)

# remove any nans
data = self.data.value[~np.any(np.isnan(self.data.value), axis=1)]

x_axis_vals = data[:, 0]
y_axis_vals = data[:, 1]

ymin = np.floor(y_axis_vals.min()).astype(int)
ymax = np.ceil(y_axis_vals.max()).astype(int)

# default selection is 25% of the image
if selection is None:
selection = (x_axis_vals[0], x_axis_vals[value_25p], ymin, ymax)

# min/max limits
limits = (x_axis_vals[0], x_axis_vals[-1], ymin * 1.5, ymax * 1.5)

selector = RectangleSelector(
selection=selection,
limits=limits,
axis=axis,
fill_color=fill_color,
parent=self,
**kwargs
)

self._plot_area.add_graphic(selector, center=False)

return selector

# TODO: this method is a bit of a mess, can refactor later
def _get_linear_selector_init_args(
self, axis: str, padding
self, axis: str, padding
) -> tuple[tuple[float, float], tuple[float, float], float, float]:
# computes args to create selectors
n_datapoints = self.data.value.shape[0]
Expand Down
Loading