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.

visibility selector
# test_example = false

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"),
#         display_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"),
#         display_dims=("l", "time", "d"),
#         x_range_mode="fixed",
#         display_window=None,
#     )
#
#     # Create selectors
#     # image highlight selector for this session
#     image_selector = fpl.ImageHighlightSelector(
#         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"
#     )
#
#     # 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.
#     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()
figure = ndw.figure

fpl.loop.run()

Total running time of the script: (0 minutes 0.934 seconds)

Gallery generated by Sphinx-Gallery