Skip to content
Merged
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
89 changes: 89 additions & 0 deletions examples/selection_tools/highlight_selector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""
Highlight Selector
==================

NDWidget with a time-varying 100x100 image (two circles driven by sine/cosine)
and a heatmap of all pixel timeseries. Clicking a row of the heatmap highlights
that row and the corresponding pixel on the image.
Shift-click appends; plain click replaces the selection.
"""

# test_example = false
# sphinx_gallery_pygfx_docs = 'screenshot'

import numpy as np
import fastplotlib as fpl
from fastplotlib.graphics import ImageGraphic
from fastplotlib.graphics.selectors import ImageHighlightSelector
from fastplotlib.utils.functions import heatmap_to_positions

# --- synthetic data ---
n_t = 100
n_y, n_x = 100, 100

rng = np.random.default_rng(0)
vol = np.zeros((n_t, n_y, n_x), dtype=np.float32)

yy, xx = np.ogrid[:n_y, :n_x]
mask1 = (yy - 30) ** 2 + (xx - 30) ** 2 < 15**2
mask2 = (yy - 70) ** 2 + (xx - 70) ** 2 < 15**2

t = np.linspace(0, 2 * np.pi, n_t)
for i in range(n_t):
vol[i, mask1] = np.sin(t[i]) + rng.normal(0, 0.05, mask1.sum())
vol[i, mask2] = np.cos(t[i]) + rng.normal(0, 0.05, mask2.sum())

# heatmap: (n_pixels, n_t), then convert to positions for add_nd_timeseries
heatmap = vol.reshape(n_t, n_y * n_x).T.astype(np.float32) # (n_pixels, n_t)
xvals = np.arange(n_t, dtype=np.float32)
heatmap_pos = heatmap_to_positions(heatmap, xvals) # (n_pixels, n_t, 2)

# --- layout ---
ndw = fpl.NDWidget(ref_ranges={"t": (0, n_t, 1)}, shape=(1, 2), size=(1400, 560))

nd_img = ndw[0, 0].add_nd_image(vol, ("t", "y", "x"), ("y", "x"), name="image")

nd_hm = ndw[0, 1].add_nd_timeseries(
heatmap_pos,
dims=("pixel", "t", "xy"),
spatial_dims=("pixel", "t", "xy"),
graphic_type=ImageGraphic,
x_range_mode="fixed",
display_window=None,
name="heatmap",
)

# --- highlight selectors ---
img_sel = ImageHighlightSelector(color="w", alpha=0.4)
img_sel.add_graphic(nd_img.graphic)

hm_sel = ImageHighlightSelector(color="w", alpha=0.4)
hm_sel.add_graphic(nd_hm.graphic)


@nd_hm.graphic.add_event_handler("double_click")
def on_heatmap_click(ev):
idx = ev.pick_info.get("index")
if idx is None:
return
# index = (col, row) = (timepoint, pixel_idx)
pixel_idx = idx[1]
row = pixel_idx // n_x
col = pixel_idx % n_x

if "Shift" in ev.modifiers:
hm_sel.append("rows", pixel_idx)
img_sel.append("pixels", np.array([[row, col]]))
print(hm_sel.selection)
else:
hm_sel.selection = {"rows": [pixel_idx]}
img_sel.selection = {"pixels": [np.array([[row, col]])]}


ndw.show(maintain_aspect=False)

# NOTE: fpl.loop.run() should not be used for interactive sessions
# See the "JupyterLab and IPython" section in the user guide
if __name__ == "__main__":
print(__doc__)
fpl.loop.run()
186 changes: 186 additions & 0 deletions examples/selection_tools/visibility_selector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
"""
Visibility and Highlight Selector
=================================

Example with an image that contains time-varying signals. An ``ImageHighlightSelector`` is created with pre-loaded
options for either contour outlines or filled masks that spatially denote a unique signal in the image. A
``VisiblitySelector`` is used on a LineCollection. When the image is clicked, the closest spatial signal is highlighted
and the corresponding line is made visible. Shift + click to multi-select signals.
"""

# test_example = false
# sphinx_gallery_pygfx_docs = 'screenshot'

from functools import partial
import numpy as np
from scipy.ndimage import binary_erosion
import fastplotlib as fpl
import cmap as cmap_lib

n_t = 500
n_y, n_x = 128, 128
n_circles = 32
radius = 4 # diameter 5

rng = np.random.default_rng(0)

# Random circle centers
centers = rng.integers(0, [n_y, n_x], size=(n_circles, 2))

yy, xx = np.ogrid[:n_y, :n_x]

movies_sessions = list()
contours_sessions = list()
signals_sessions = list()
centers_per_session = list()
indices_per_session = list()

# just generate multi-session toy data
for session_index in range(3):
masks = []
contours = [] # perimeter pixel coordinates per circle

for cy, cx in centers:
mask = (yy - cy) ** 2 + (xx - cx) ** 2 <= radius**2
masks.append(mask)
# Perimeter = filled mask minus its erosion
perimeter = mask # & ~binary_erosion(mask)
contours.append(np.argwhere(perimeter)) # shape (K, 2), columns are [y, x]

images = np.zeros((n_t, n_y, n_x), dtype=np.float32)
t = np.linspace(0, 10 * np.pi, n_t)
phases = 2 * np.pi * np.arange(n_circles) / n_circles

signals = list()
for j, mask in enumerate(masks):
signal = np.sin(t + phases[j]).astype(np.float32) # (n_t,)
noise = rng.normal(0, 0.05, (n_t, mask.sum())).astype(np.float32) # (n_t, K)
signal = signal[:, None] + noise
images[:, mask] += signal
signals.append(signal.mean(axis=1))

signals = np.stack(signals)

# just to create diff indices per session
local_indices = np.roll(np.arange(n_circles), shift=session_index)

indices_per_session.append(local_indices)

movies_sessions.append(images)

# re-order stuff in local index order
centers_per_session.append(centers[local_indices])
contours_sessions.append([contours[i] for i in local_indices])
signals_sessions.append(signals[local_indices])


# Just NDWidget & figure stuff
extents = {
"images-0": (0, 0.33, 0, 0.33),
"signals-0": (0.33, 1, 0, 0.33),
"images-1": (0, 0.33, 0.33, 0.67),
"signals-1": (0.33, 1, 0.33, 0.67),
"images-2": (0, 0.33, 0.67, 1),
"signals-2": (0.33, 1, 0.67, 1),
}

ref_range = {"time": (0, n_t, 1)}
ndw = fpl.NDWidget(
ref_range,
extents=extents,
controller_ids=[
("images-0", "images-1", "images-2"),
],
size=(1300, 1000)
)

# create selection vector
sv = fpl.SelectionVector()

# mapping to go from master index -> per session index for a given session
# this must be a vector -> vector mapping since multiple things can be selected
def master_to_local_index(session_id: int, selection_indices: list[int]) -> list[int]:
return [i + session_id for i in selection_indices]


# image click changes the selection, can change the selection vector in any other way too
def image_clicked(session, ev):
col, row = ev.pick_info["index"]

local_index = np.argmin(
np.linalg.norm(centers_per_session[session] - np.array([row, col]), axis=1)
)

# inverse transform, local scalar index -> master index
master_index = local_index - session

print(local_index, master_index)

global sv

if "Shift" in ev.modifiers:
sv.append(master_index)
else:
# just one item selected
sv.selection = [master_index]

for subplot in ndw.figure:
if "signals" in subplot.name:
subplot.auto_scale()


# iterate through all the toy data, create NDGraphics and selectors
for session_index, (indices, movie, contours, signals) in enumerate(
zip(indices_per_session, movies_sessions, contours_sessions, signals_sessions)
):
# create NDImage, nothing special here
ndi = ndw[f"images-{session_index}"].add_nd_image(
movie,
dims=("time", "m", "n"),
spatial_dims=list("mn"),
)
ndi.graphic.cmap = "gray"
# create ND Timeseries, again nothing special
ndt = ndw[f"signals-{session_index}"].add_nd_timeseries(
fpl.utils.heatmap_to_positions(signals, xvals=np.arange(0, n_t)),
dims=("l", "time", "d"),
spatial_dims=("l", "time", "d"),
x_range_mode="fixed",
display_window=None,
)

# Create selectors
# image highlight selector for this session
image_selector = fpl.ImageHighlightSelector(
ndi.graphic, # target graphic, you can also add more target graphics later
# as long as they are in the same "selection space", ex: each movie for single-session
# each selector manages ONE buffer, so the same pixels will be highlighted on all graphics
# targetted by a selector.
lut="tab10",
selection_options={"pixels": contours}, # pre-loaded selection options
options_alpha=0.1, # unselected contours shown with low alpha
options_color="w", # unselected contours shown this color
lut_wrap="repeat", # cycles through tab10 colormap if you select > 10 items
alpha=0.7, # highlight alpha
)

# selector that toggles visibility of lines in the line stack
# use same lut as the image highlight
traces_visible_selector = fpl.VisibilitySelector(
ndt.graphic, lut="tab10", lut_wrap="repeat"
)

# image selector targets the image graphic for this session
image_selector.add_graphic(ndi.graphic)
# when image is double clicked, calls the handler
ndi.graphic.add_event_handler(partial(image_clicked, session_index), "double_click")

# add selectors to SelectionVector
# with mapping that defines how to map from master index to local index for this session
mapping = partial(master_to_local_index, session_index)
sv.add_selector((image_selector, mapping))
sv.add_selector((traces_visible_selector, mapping))

ndw.show()

fpl.loop.run()
17 changes: 15 additions & 2 deletions fastplotlib/graphics/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import pygfx
from pygfx import Texture

from .shaders import HighlightableImageMaterial
from ..utils import quick_min_max, ColorspacesRGB, ColorspacesYUV, ColorRange
from ._base import Graphic
from .selectors import (
Expand Down Expand Up @@ -48,11 +49,23 @@ def __init__(
chunk_index: tuple[int, int],
**kwargs,
):
self._vis_scale = None # (axis_index, scale) set by ImageVisibilitySelector
super().__init__(geometry, material, **kwargs)

self._data_slice = data_slice
self._chunk_index = chunk_index

def get_bounding_box(self):
aabb = super().get_bounding_box()
if aabb is None or self._vis_scale is None:
return aabb
ax_i, scale = self._vis_scale
if scale == 0.0:
return None
aabb = aabb.copy()
aabb[1, ax_i] = aabb[0, ax_i] + (aabb[1, ax_i] - aabb[0, ax_i]) * scale
return aabb

def _wgpu_get_pick_info(self, pick_value):
pick_info = super()._wgpu_get_pick_info(pick_value)

Expand Down Expand Up @@ -489,7 +502,7 @@ def __init__(
)

# one common material is used for every Texture chunk
self._material = pygfx.ImageBasicMaterial(
self._material = HighlightableImageMaterial(
clim=(vmin, vmax),
map=_map,
interpolation=self._interpolation.value,
Expand Down Expand Up @@ -718,7 +731,7 @@ def __init__(

self._interpolation = ImageInterpolation(interpolation)

self._material = pygfx.ImageBasicMaterial(
self._material = HighlightableImageMaterial(
clim=(vmin, vmax), interpolation=self.interpolation, pick_write=True
)

Expand Down
1 change: 1 addition & 0 deletions fastplotlib/graphics/line_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -655,4 +655,5 @@ def __init__(
axis_zero + line.data.value[:, axes[separation_axis]].max() + separation
)

self.separation_axis = separation_axis
self.separation = separation
13 changes: 9 additions & 4 deletions fastplotlib/graphics/scatter_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -643,11 +643,16 @@ def __init__(
**kwargs,
)

self._sepration_axis = separation_axis
self._separation_axis = separation_axis
self._separation = separation

self.separation = separation

@property
def separation_axis(self) -> str:
"""axis along which the graphics are separated: ``'x'`` or ``'y'``"""
return self._separation_axis

@property
def separation(self) -> float:
"""distance between each line in the stack, in world space"""
Expand All @@ -659,14 +664,14 @@ def separation(self, value: float):

axis_zero = 0
for i, line in enumerate(self.graphics):
if self._sepration_axis == "x":
if self._separation_axis == "x":
line.offset = (axis_zero, *line.offset[1:])

elif self._sepration_axis == "y":
elif self._separation_axis == "y":
line.offset = (line.offset[0], axis_zero, line.offset[2])

axis_zero = (
axis_zero + line.data.value[:, axes[self._sepration_axis]].max() + separation
axis_zero + line.data.value[:, axes[self._separation_axis]].max() + separation
)

self._separation = value
Loading