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
9 changes: 4 additions & 5 deletions examples/selection_tools/visibility_selector.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,6 @@ def image_clicked(session, ev):
# 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
Expand All @@ -170,7 +166,10 @@ def image_clicked(session, ev):
ndt.graphic, lut="tab10", lut_wrap="repeat"
)

# image selector targets the image graphic for this session
# 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")
Expand Down
5 changes: 4 additions & 1 deletion fastplotlib/graphics/features/_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def __init__(
data,
property_name: str = "data",
cpu_buffer: bool = True,
usage: wgpu.TextureUsage = 0,
colorspace: ColorspacesRGB = ColorspacesRGB.srgb,
):
super().__init__(property_name=property_name)
Expand All @@ -60,9 +61,10 @@ def __init__(
# create a local buffer
self._value = np.empty(data.shape, dtype=data.dtype)
self.value[:] = data[:]
usage = usage
else:
self._value = None
usage = wgpu.TextureUsage.COPY_DST
usage = wgpu.TextureUsage.COPY_DST | usage
# auto-determine format, adapted from pygfx.Texture
element_format = get_element_format_from_numpy_array(data)
if element_format is None:
Expand Down Expand Up @@ -106,6 +108,7 @@ def __init__(
self.value[slicer],
dim=2,
colorspace=colorspace,
usage=usage
)
else:
# we only supply the size
Expand Down
16 changes: 13 additions & 3 deletions fastplotlib/graphics/selectors/_highlight_selector.py
Original file line number Diff line number Diff line change
Expand Up @@ -570,7 +570,7 @@ def __init__(
super().__init__(color=color, lut=lut, alpha=alpha, lut_wrap=lut_wrap)

self._selection: dict[str, list] = dict()
self._selected_indices: list[int] = list()
self._selected_indices: list[int | None] = list()
self._options_color = options_color
self._options_alpha = float(options_alpha)

Expand Down Expand Up @@ -669,7 +669,7 @@ def options_alpha(self, value: float) -> None:
self._update_all_graphics()

@property
def selection(self) -> tuple[int, ...] | dict[str, tuple]:
def selection(self) -> tuple[int | None, ...] | dict[str, tuple]:
"""
In options mode: tuple of selection option indices.
In free mode: dict of selection items.
Expand All @@ -690,7 +690,7 @@ def selection(self, value: Iterable[int] | dict[Literal["rows", "cols", "pixels"
self._selected_indices = [value]

else:
self._selected_indices = [int(i) for i in value]
self._selected_indices = [int(i) if i is not None else None for i in value]

else:
if not value:
Expand Down Expand Up @@ -837,15 +837,23 @@ def _create_mask(self, n_rows: int, n_cols: int) -> np.ndarray:
)
# start=1 since 0 indicates unselected placeholder value
for i, (rs, cs) in enumerate(zip(sel["rows"], sel["cols"]), start=1):
if rs is None or cs in None:
continue
mask[rs, cs] = i
elif "rows" in sel:
for i, rs in enumerate(sel["rows"], start=1):
if rs is None:
continue
mask[rs, :] = i
elif "cols" in sel:
for i, cs in enumerate(sel["cols"], start=1):
if cs in None:
continue
mask[:, cs] = i
elif "pixels" in sel:
for i, px in enumerate(sel["pixels"], start=1):
if px is None:
continue
arr = np.asarray(px)
mask[arr[:, 0], arr[:, 1]] = i
return mask
Expand All @@ -868,6 +876,8 @@ def _fill_lut(self) -> None:
)
current_lut[:, -1] *= self._alpha
for i, sel in enumerate(self._selected_indices):
if sel is None:
continue
lut_buffer[sel] = current_lut[i]
else:
n = self._len_dict(self._selection)
Expand Down
97 changes: 79 additions & 18 deletions fastplotlib/graphics/selectors/_visibility_selector.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ def _validate_int_collection(value, name: str) -> set | int:

s = set(value)

if not all(isinstance(i, Integral) for i in s):
raise TypeError(f"{name} must contain only integers, got: {s!r}")
if not all(isinstance(i, Integral) or i is None for i in s):
raise TypeError(f"{name} must contain only integers or None, got: {s!r}")

return value

Expand Down Expand Up @@ -69,7 +69,7 @@ def __init__(
raise ValueError(f"lut_wrap must be 'fixed' or 'repeat', got {lut_wrap!r}")

self._collection = collection
self._selection: list[int] = []
self._selection: list[int | None] = []
self._event_handlers: list[Callable] = []
self._lut_wrap = lut_wrap

Expand Down Expand Up @@ -109,16 +109,18 @@ def __del__(self):
g.colors = self._original_colors[i]

@property
def selection(self) -> tuple[int, ...]:
def selection(self) -> tuple[int | None, ...]:
"""Get or set the selection"""
return tuple(self._selection)

@selection.setter
def selection(self, new_selection: Iterable[int] | int):
def selection(self, new_selection: Iterable[int | None] | int):
if new_selection:
_validate_int_collection(new_selection, "selection")

for index in self._selection:
if index is None:
continue
# set any selected things to be invisible
self._collection.graphics[index].visible = False

Expand All @@ -128,6 +130,8 @@ def selection(self, new_selection: Iterable[int] | int):
self._selection = list(new_selection) if new_selection else list()

for index in self._selection:
if index is None:
continue
# set the new selection to be visible
self._collection.graphics[index].visible = True

Expand All @@ -139,13 +143,15 @@ def selection(self, new_selection: Iterable[int] | int):

def append(self, item: int):
"""Add an index to the selection. Already-selected indices are skipped."""
if not isinstance(item, Integral):
raise TypeError(f"item must be an integer, got {type(item)}")
if not isinstance(item, Integral) and item is not None:
raise TypeError(f"item must be an integer or None, got {type(item)}")

if item in self._selection:
if item in self._selection and item is not None:
return

self._collection.graphics[item].visible = True
if item is not None:
self._collection.graphics[item].visible = True

self._selection.append(item)

if self._is_stack:
Expand All @@ -171,13 +177,41 @@ def remove(self, item: int):
self._apply_lut()
self._emit({"value": list(self._selection)})

def pop(self, index: int):
"""pop item at the given index"""

if not isinstance(index, Integral):
raise TypeError(
f"pop argument must be an integer, got: {type(index).__name__}"
)

if index >= len(self):
raise IndexError(
f"index: {index} out of bounds for {self.__class__.__name__} with length: {len(self)}"
)

item = self._selection[index]
if item is not None:
self._collection.graphics[item].visible = False

self._selection.pop(index)

if self._is_stack:
self._restack()

self._apply_lut()
self._emit({"value": list(self._selection)})

def clear(self) -> None:
"""Hide all graphics. Stack offsets are left as-is."""
for idx in self._selection:
if idx is None:
continue
self._collection.graphics[idx].visible = False

self._selection = list()
self._emit({"value": []})

@property
def lut(self) -> np.ndarray | None:
"""Optional per-item colors, shape ``(n, 4)`` float32 RGBA"""
Expand All @@ -201,6 +235,8 @@ def _apply_lut(self) -> None:
color=None, lut=self._lut, n=len(self._selection), lut_wrap=self._lut_wrap
)
for sel_index, graphic_index in enumerate(self._selection):
if graphic_index is None:
continue
self._collection.graphics[graphic_index].colors = colors[sel_index]

def _restack(self) -> None:
Expand All @@ -209,6 +245,9 @@ def _restack(self) -> None:

distance = 0.0
for index in self._selection:
if index is None:
continue

g = self._collection.graphics[index]
offset = list(g.offset)
offset[ax_i] = distance
Expand Down Expand Up @@ -243,10 +282,7 @@ def __iter__(self):
return iter(self._selection)

def __repr__(self) -> str:
return (
f"VisibilitySelector\n"
f"selection: {self._selection}"
)
return f"VisibilitySelector\n" f"selection: {self._selection}"


class ImageVisibilitySelector:
Expand Down Expand Up @@ -334,12 +370,12 @@ def selection(self, value: Iterable[int]):
self._update_material()
self._emit({"value": tuple(self._selection)})

def append(self, item) -> None:
def append(self, item: int | None):
"""add a row/col index to the selection"""
if not isinstance(item, Integral):
raise TypeError(f"item must be an integer, got {type(item)}")
if not isinstance(item, Integral) and item is not None:
raise TypeError(f"item must be an integer or None, got {type(item)}")

if item in self._selection:
if item in self._selection and item is not None:
return

self._selection.append(item)
Expand All @@ -359,6 +395,22 @@ def remove(self, item) -> None:
self._update_material()
self._emit({"value": list(self._selection)})

def pop(self, index: int):
"""pop item at the given index"""
if not isinstance(index, Integral):
raise TypeError(
f"pop argument must be an integer, got: {type(index).__name__}"
)

if index >= len(self):
raise IndexError(
f"index: {index} out of bounds for {self.__class__.__name__} with length: {len(self)}"
)

self._selection.pop(index)
self._update_material()
self._emit({"value": list(self._selection)})

def clear(self) -> None:
"""Clear the selection (all invisible, fpl_n_visible=0)."""
self._selection = list()
Expand All @@ -369,7 +421,16 @@ def _update_material(self) -> None:
mat = self._graphic._material
n = len(self._selection)
if n > 0:
mat._vis_lut_buffer.data[:n] = np.array(self._selection, dtype=np.uint32)
mat._vis_lut_buffer.data[:n] = np.array(
list(
map(
# 0xFFFFFFFF, 2^32 - 1, indicates None vals and shader discard
lambda x: x if x is not None else np.uint32(0xFFFFFFFF),
self._selection,
)
),
dtype=np.uint32,
)

mat._vis_lut_buffer.update_range()
mat.uniform_buffer.data["fpl_n_visible"] = np.uint32(n)
Expand Down
7 changes: 6 additions & 1 deletion fastplotlib/graphics/shaders/_highlight_shaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,12 @@
let fpl_vis_px = vec2<u32>(varyings.texcoord * sizef);
let fpl_vis_idx = select(fpl_vis_px.x, fpl_vis_px.y, u_material.fpl_vis_axis_y == 1u);
if (fpl_vis_idx >= u_material.fpl_n_visible) { discard; }
let fpl_src_f = f32(s_vis_lut[fpl_vis_idx]);

// discard Nones which we map to 0xFFFFFFFF
let fpl_src_u = s_vis_lut[fpl_vis_idx];
if (fpl_src_u == 0xFFFFFFFFu) { discard; }
let fpl_src_f = f32(fpl_src_u);

if (u_material.fpl_vis_axis_y == 1u) {
fpl_texcoord.y = (fpl_src_f + 0.5) / sizef.y;
} else {
Expand Down