Source code for fastplotlib.graphics.selectors._highlight_selector

from __future__ import annotations

from typing import Iterable
from numbers import Integral
from typing import Callable, Literal
from warnings import warn

import cmap as cmap_lib
import numpy as np
import pygfx
import wgpu

from .._collection_base import GraphicCollection
from ..shaders._highlight_materials import (
    HighlightableLineMaterial,
    HighlightableLineThinMaterial,
    HighlightablePointsMaterial,
    HighlightablePointsMarkerMaterial,
    HighlightablePointsSpriteMaterial,
    HighlightablePointsGaussianBlobMaterial,
    HighlightableImageMaterial,
)

_POSITIONS_MATERIAL_TYPES = (
    HighlightableLineMaterial,
    HighlightableLineThinMaterial,
    HighlightablePointsMaterial,
    HighlightablePointsMarkerMaterial,
    HighlightablePointsSpriteMaterial,
    HighlightablePointsGaussianBlobMaterial,
)


def _build_lut(
    color: str | np.ndarray = "red",
    lut: str | np.ndarray | None = None,
    n: int = 1,
    lut_wrap: Literal["fixed", "repeat"] = "fixed",
) -> np.ndarray:
    """
    Return an (n, 4) float32 RGBA array for n selected items.
    """

    if n == 0:
        return np.zeros((1, 4), dtype=np.float32)

    if lut is not None:
        if isinstance(lut, str):
            lut = cmap_lib.Colormap(lut).lut(n)

        lut = np.asarray(lut, dtype=np.float32)

        if lut.ndim != 2 or lut.shape[1] != 4:
            raise ValueError("`lut` must have shape (n, 4) for n selected items")

        if lut_wrap == "repeat":
            return lut[np.arange(n) % len(lut)]

        if lut.shape[0] < n:
            raise ValueError(
                f"`lut` has only {lut.shape[0]} entries but {n} are selected"
            )

        return lut[:n]

    return np.repeat([pygfx.Color(color)], n, axis=0)


[docs] class HighlightSelector: """ Base class managing highlight state on one or more graphics. Highlights selected vertices or image regions by blending a color into the rendered output. Does not create extra world objects, so ``pick_info`` is unaffected. Use the subclasses: * :class:`PositionsHighlightSelector`: highlight individual vertices on a LineGraphic or ScatterGraphic * :class:`CollectionHighlightSelector`: highlight whole lines/scatters in a collection * :class:`ImageHighlightSelector`: highlight pixel regions of an ImageGraphic """ def __init__( self, color: str | np.ndarray = "red", lut: str | np.ndarray | None = None, lut_wrap: Literal["fixed", "repeat"] = "fixed", alpha: float = 0.7, ): if lut_wrap not in ("fixed", "repeat"): raise ValueError(f"lut_wrap must be 'fixed' or 'repeat', got {lut_wrap!r}") self._color = color self._lut = lut self._alpha = float(alpha) self._lut_wrap = lut_wrap self._graphics = list() self._event_handlers: list[Callable] = list() @property def selection(self): raise NotImplementedError @selection.setter def selection(self, value): raise NotImplementedError
[docs] def append(self, item) -> None: raise NotImplementedError
[docs] def remove(self, item) -> None: raise NotImplementedError
[docs] def clear(self) -> None: raise NotImplementedError
@property def color(self) -> str | np.ndarray: """ Get or set color applied to all selected items, used if ``lut`` is ``None``. Accepts any value that ``pygfx.Color`` understands (color name string, RGBA tuple, hex string, etc.). """ return self._color @color.setter def color(self, value): self._color = value self._update_all_graphics() @property def lut(self) -> str | np.ndarray | None: """ Get or set per-item color lookup table, shape ``(n, 4)`` float32 RGBA, or a str that defines a colormap. When set, ``lut[i]`` is the highlight color for the i-th selected item. Must have at least as many rows as the number of selected items. Set to ``None`` to fall back to ``color``. """ return self._lut @lut.setter def lut(self, value: np.ndarray | None): self._lut = value self._update_all_graphics() @property def lut_wrap(self) -> str: """ Get or set LUT wrap mode. - "fixed": no wrapping, fixed to size of the given LUT - "repeat": cycles through the colormap when n_selections > lut_size""" return self._lut_wrap @property def alpha(self) -> float: """Get or set alpha value, 0 - 1.0""" return self._alpha @alpha.setter def alpha(self, value: float): self._alpha = float(value) self._update_all_graphics() @property def graphics(self) -> list: """Get graphics the highlight selector is operating on.""" return list(self._graphics)
[docs] def add_graphic(self, graphic) -> None: """Add ``graphic`` and apply the current highlight selection to it.""" if graphic in self._graphics: warn(f"{graphic!r} is already attached to this selector.") return self._check_graphic(graphic) self._graphics.append(graphic) self._update_highlight_buffers(graphic)
[docs] def remove_graphic(self, graphic) -> None: """remove ``graphic`` and clear its highlight buffer.""" if graphic not in self._graphics: raise KeyError(f"{graphic!r} is not attached to this selector.") self._graphics.remove(graphic) self._clear_highlight_buffers(graphic)
def _check_graphic(self, graphic) -> None: raise NotImplementedError def _update_highlight_buffers(self, graphic) -> None: raise NotImplementedError def _clear_highlight_buffers(self, graphic) -> None: raise NotImplementedError
[docs] def add_event_handler(self, handler: Callable) -> None: """Add a callback that is called when the selection changes.""" if not callable(handler): raise TypeError("event handler must be callable") if handler in self._event_handlers: warn(f"{handler} is already registered.") return self._event_handlers.append(handler)
[docs] def remove_event_handler(self, handler: Callable) -> None: """Remove an event handler.""" if handler not in self._event_handlers: raise KeyError(f"{handler} is not registered.") self._event_handlers.remove(handler)
def _emit(self, info: dict) -> None: for h in self._event_handlers: h({"selector": self, **info}) def _update_all_graphics(self) -> None: for g in self._graphics: self._update_highlight_buffers(g) @staticmethod def _write_ids(material, ids: np.ndarray) -> None: # replace buffer if size changed (GPU binding must point to new object) if material._highlight_ids_buffer.data.shape[0] != ids.shape[0]: material._highlight_ids_buffer = pygfx.Buffer(ids.copy()) else: material._highlight_ids_buffer.data[:] = ids material._highlight_ids_buffer.update_range() @staticmethod def _write_lut(material, lut: np.ndarray) -> None: # replace buffer if size changed (GPU binding must point to new object) if material._highlight_lut_buffer.data.shape[0] != lut.shape[0]: material._highlight_lut_buffer = pygfx.Buffer(lut.copy()) else: material._highlight_lut_buffer.data[:] = lut material._highlight_lut_buffer.update_range() def __len__(self) -> int: raise NotImplementedError def __contains__(self, item) -> bool: raise NotImplementedError def __iter__(self): raise NotImplementedError def __repr__(self) -> str: return f"{self.__class__.__name__}\n" f"selection: {self.selection}"
[docs] class PositionsHighlightSelector(HighlightSelector): """ Highlights individual data points on a LineGraphic or ScatterGraphic. Parameters ---------- color : str or array-like, default "cyan" Color applied to all selected vertices when no ``lut`` is set. lut : np.ndarray, optional Per-vertex RGBA colors of shape (n, 4); ``lut[i]`` applies to the i-th selected vertex. lut_wrap: "fixed" or "repeat" - "fixed": no wrapping, fixed to size of the given LUT - "repeat": cycles through the colormap when n_selections > lut_size alpha : float, default 1.0 Highlight blend strength in [0, 1]. """ def __init__( self, color: str | np.ndarray = "red", lut: str | np.ndarray | None = None, lut_wrap: Literal["fixed", "repeat"] = "fixed", alpha: float = 1.0, ): super().__init__(color=color, lut=lut, lut_wrap=lut_wrap, alpha=alpha) self._selection: list[int] = list() @property def selection(self) -> tuple[int, ...]: """ Get or set selected vertex indices. """ return tuple(self._selection) @selection.setter def selection(self, value) -> None: if value is None or len(value) == 0: self._selection = list() else: if isinstance(value, Integral): value = [value] if not all([isinstance(i, Integral) for i in value]): raise TypeError(f"selection must be an iterable of <int>\ngot: {value}") # convert to list self._selection = list(map(int, value)) self._update_all_graphics() self._emit({"value": tuple(self._selection)}) # TODO: need to review the rest of these method
[docs] def append(self, item) -> None: """ Append one or more vertex indices to the selection. Indices already in the selection are silently skipped. """ new = [int(i) for i in np.asarray(item).ravel()] novel = [i for i in new if i not in self._selection] if novel: self._selection.extend(novel) self._update_all_graphics() self._emit({"value": list(self._selection)})
[docs] def remove(self, item) -> None: """Remove one or more vertex indices from the selection.""" to_remove = set(int(i) for i in np.asarray(item).ravel()) self._selection = [i for i in self._selection if i not in to_remove] self._update_all_graphics() self._emit({"value": list(self._selection)})
[docs] def clear(self) -> None: """Remove all highlights.""" self._selection = [] self._update_all_graphics() self._emit({"value": []})
def _check_graphic(self, graphic) -> None: mat = graphic.world_object.material if not isinstance(mat, _POSITIONS_MATERIAL_TYPES): raise TypeError( f"PositionsHighlightSelector requires a graphic using one of " f"{[t.__name__ for t in _POSITIONS_MATERIAL_TYPES]}, " f"got {type(mat).__name__}." ) def _update_highlight_buffers(self, graphic) -> None: mat = graphic.world_object.material mat.uniform_buffer.data["highlight_alpha"] = self._alpha mat.uniform_buffer.update_range() n_vertices = graphic.data.value.shape[0] ids = np.zeros(n_vertices, dtype=np.uint32) for rank, idx in enumerate(self._selection): if 0 <= idx < n_vertices: ids[idx] = rank + 1 self._write_ids(mat, ids) self._write_lut( mat, _build_lut(self._color, self._lut, len(self._selection), self._lut_wrap), ) def _clear_highlight_buffers(self, graphic) -> None: mat = graphic.world_object.material n_vertices = graphic.data.value.shape[0] self._write_ids(mat, np.zeros(n_vertices, dtype=np.uint32)) self._write_lut(mat, np.zeros((1, 4), dtype=np.float32)) def __len__(self) -> int: return len(self._selection) def __contains__(self, item) -> bool: return int(item) in self._selection def __iter__(self): return iter(self._selection) def __repr__(self) -> str: return ( f"PositionsHighlightSelector(" f"selection={self._selection}, " f"n_graphics={len(self._graphics)})" )
# TODO: review
[docs] class CollectionHighlightSelector(HighlightSelector): """ Highlights entire graphics within a LineCollection or ScatterCollection. Each selected collection item is highlighted with a single color across all of its vertices. Parameters ---------- color : str or array-like, default "cyan" Color applied to all selected items when no ``lut`` is set. lut : np.ndarray, optional Per-item RGBA colors of shape (k, 4); ``lut[i]`` applies to the i-th selected item. Must have at least as many rows as the number of selected items. alpha : float, default 1.0 Highlight blend strength in [0, 1]. """ def __init__( self, color: str | np.ndarray = "cyan", lut: np.ndarray | None = None, alpha: float = 1.0, ): super().__init__(color=color, lut=lut, alpha=alpha) self._selection: list[int] = [] @property def selection(self) -> list[int]: """ Selected collection indices. Assign a list or array of integer indices to set the selection. Empty selection is represented as ``[]``. """ return list(self._selection) @selection.setter def selection(self, value) -> None: if value is None or len(value) == 0: self._selection = [] else: self._selection = [int(i) for i in np.asarray(value).ravel()] self._update_all_graphics() self._emit({"value": list(self._selection)})
[docs] def append(self, item) -> None: """ Append one or more collection indices to the selection. Indices already in the selection are silently skipped. """ new = [int(i) for i in np.asarray(item).ravel()] novel = [i for i in new if i not in self._selection] if novel: self._selection.extend(novel) self._update_all_graphics() self._emit({"value": list(self._selection)})
[docs] def remove(self, item) -> None: """Remove one or more collection indices from the selection.""" to_remove = set(int(i) for i in np.asarray(item).ravel()) self._selection = [i for i in self._selection if i not in to_remove] self._update_all_graphics() self._emit({"value": list(self._selection)})
[docs] def clear(self) -> None: """Remove all highlights.""" self._selection = [] self._update_all_graphics() self._emit({"value": []})
def _check_graphic(self, graphic) -> None: if not isinstance(graphic, GraphicCollection): raise TypeError( f"CollectionHighlightSelector requires a GraphicCollection, " f"got {type(graphic).__name__}." ) def _update_highlight_buffers(self, graphic) -> None: n_items = len(graphic) sel = self._selection lut = _build_lut(self._color, self._lut, len(sel), self._lut_wrap) rank_map = {idx: rank + 1 for rank, idx in enumerate(sel) if 0 <= idx < n_items} for i, sub_graphic in enumerate(graphic): sub_mat = sub_graphic.world_object.material if not isinstance(sub_mat, _POSITIONS_MATERIAL_TYPES): continue sub_mat.uniform_buffer.data["highlight_alpha"] = self._alpha sub_mat.uniform_buffer.update_range() n_vertices = sub_graphic.data.value.shape[0] id_val = np.uint32(rank_map.get(i, 0)) self._write_ids(sub_mat, np.full(n_vertices, id_val, dtype=np.uint32)) self._write_lut(sub_mat, lut) def _clear_highlight_buffers(self, graphic) -> None: for sub_graphic in graphic: sub_mat = sub_graphic.world_object.material if not isinstance(sub_mat, _POSITIONS_MATERIAL_TYPES): continue n_vertices = sub_graphic.data.value.shape[0] self._write_ids(sub_mat, np.zeros(n_vertices, dtype=np.uint32)) self._write_lut(sub_mat, np.zeros((1, 4), dtype=np.float32)) def __len__(self) -> int: return len(self._selection) def __contains__(self, item) -> bool: return int(item) in self._selection def __iter__(self): return iter(self._selection) def __repr__(self) -> str: return ( f"CollectionHighlightSelector(" f"selection={self._selection}, " f"n_graphics={len(self._graphics)})" )
[docs] class ImageHighlightSelector(HighlightSelector): """ Highlights pixel regions of an ImageGraphic. Can be used in two modes: **Free-selection mode**, if ``selection_options`` is ``None``: ``selection`` is a dict with keys: - "rows": list of row specs (int, list[int], or slice); selects those rows across all cols. - "cols": list of col specs (int, list[int], or slice); selects those cols across all rows. - "pixels": list of ``(n, 2)`` arrays of ``[[row, col], ...]`` coordinates. When both "rows" and "cols" are given they must have the same length each pair defines a rectangle. **Options mode**, if ``selection_options`` is set: All options are shown with ``options_color`` & ``options_alpha``. ``selection`` is an ``int`` or ``list[int]`` indexing into the options, selected items are shown with the highlight ``color`` or ``lut`` & ``alpha``. Only the LUT is rewritten on selection change, not the mask. Parameters ---------- color : str or array-like, default "red" Highlight color for selected items. lut : np.ndarray, optional RGBA colors of shape (n, 4), one for each selected item alpha : float, default 1.0 alpha blending value options_color : str or array-like, default "w" Color shown for unselected option items. options_alpha : float, default 0.1 alpha blend value for unselected items selection_options : dict or None, optional Pool of selectable options (same dict format as ``selection`` in free-selection mode). """ _VALID_KEYS = frozenset(("rows", "cols", "pixels")) def __init__( self, color: str | np.ndarray = "red", lut: str | np.ndarray | None = None, alpha: float = 0.7, lut_wrap: str = "fixed", options_color: str | np.ndarray = "w", options_alpha: float = 0.1, selection_options: dict | None = None, ): super().__init__(color=color, lut=lut, alpha=alpha, lut_wrap=lut_wrap) self._selection: dict[str, list] = dict() self._selected_indices: list[int | None] = list() self._options_color = options_color self._options_alpha = float(options_alpha) # 65535 is the highest number that uint16 can represent. # We make a LUT of this (65535 - 1) since the highlight mask Texture is uint16 # and 0 is uesd to indicate the placeholder locations for "selection_options" self._lut_buffer = pygfx.Buffer(np.zeros((65534, 4), dtype=np.float32)) self._mask_texture: pygfx.Texture | None = None # validate and store selection_options without triggering _update_all_graphics # no graphics are targeted yet if selection_options is not None: for k in selection_options: if k not in self._VALID_KEYS: raise ValueError( f"Unknown key {k!r}. Must be one of {self._VALID_KEYS}" ) self._selection_options: dict[str, list] | None = { k: list(v) for k, v in selection_options.items() } else: self._selection_options = None def _len_dict(self, sel: dict) -> int: if "rows" in sel: # covers the case for a selection of rows, as well as row & col pairs return len(sel["rows"]) if "cols" in sel: return len(sel["cols"]) if "pixels" in sel: return len(sel["pixels"]) return 0 @staticmethod def _rgba(color, alpha: float) -> np.ndarray: c = np.array(pygfx.Color(color), dtype=np.float32) c[3] = float(alpha) return c @property def selection_options(self) -> dict[str, tuple] | None: """ Get or set a pool of selectable items (same dict format as ``selection`` in free mode). When set, all options highlighted using ``options_color`` and ``options_alpha``. ``selection`` indexes into this pool. Setting to ``None`` reverts to free-selection mode and clears the selection. """ if self._selection_options is None: return None # return a new dict with a tuple of the selections so the user can't modify the objects return {k: tuple(v) for k, v in self._selection_options.items()} @selection_options.setter def selection_options(self, value: dict | None) -> None: if value is None: self._selection_options = None else: for k in value: if k not in self._VALID_KEYS: raise ValueError( f"Unknown key {k!r}. Must be one of {self._VALID_KEYS}" ) self._selection_options = {k: list(v) for k, v in value.items()} self._selected_indices = list() self._selection = dict() self._update_all_graphics() self._emit({"value": self.selection}) @property def options_color(self) -> str | np.ndarray: """Get or set color for unselected option items (options mode only).""" return self._options_color @options_color.setter def options_color(self, value: str | np.ndarray) -> None: self._options_color = value if self._selection_options is not None: self._update_all_graphics() @property def options_alpha(self) -> float: """Get or set alpha blend value of unselected option items (options mode only).""" return self._options_alpha @options_alpha.setter def options_alpha(self, value: float) -> None: self._options_alpha = float(value) if self._selection_options is not None: self._update_all_graphics() @property def selection(self) -> tuple[int | None, ...] | dict[str, tuple]: """ In options mode: tuple of selection option indices. In free mode: dict of selection items. """ if self._selection_options is not None: return tuple(self._selected_indices) # return a new dict with a tuple of the selections so the user can't modify the objects return {k: tuple(v) for k, v in self._selection.items()} @selection.setter def selection( self, value: ( Iterable[int | None] | dict[Literal["rows", "cols", "pixels"], list] | None ), ) -> None: if self._selection_options is not None: if value is None: self._selected_indices = list() elif isinstance(value, int): self._selected_indices = [value] else: self._selected_indices = [ int(i) if i is not None else None for i in value ] else: if not value: self._selection = {} else: for k in value: if k not in self._VALID_KEYS: raise ValueError( f"Unknown key {k!r}. Must be one of {self._VALID_KEYS}" ) self._selection = {k: list(v) for k, v in value.items()} self._update_all_graphics() self._emit({"value": self.selection})
[docs] def append(self, dict_or_index: dict | int) -> None: """ append to the current selection """ if self._selection_options is not None: # options mode index = dict_or_index if not isinstance(index, Integral) and index is not None: raise TypeError( f"must provide integer index to append to selection " f"in 'options' mode, you passed: {dict_or_index!r}" ) if index not in self._selected_indices or index is None: self._selected_indices.append(index) self._update_all_graphics() self._emit({"value": self.selection}) else: d = dict_or_index # check that dict is valid keys = list(d.keys()) err = f"must provide a dict of only rows, cols, rows & cols, or pixels, you passed a dict with keys: {keys}" if any([k not in self._VALID_KEYS for k in keys]): raise KeyError(err) if "pixels" in keys and len(keys) > 1: raise KeyError(err) if "rows" in keys and "cols" in keys: if len(d["rows"]) != len(d["cols"]): raise ValueError( f"if appending pairs of rows & cols, they must be of the same length" ) rows, cols = d["rows"], d["cols"] if not all( [ isinstance(r, slice) and isinstance(c, slice) for r, c in zip(rows, cols) ] ): raise ValueError( f"if appending pairs of rows & cols, each row and column pair must be a slice, you passed: {d}" ) for k in keys: self._selection.setdefault(k, list()).append(d[k]) self._update_all_graphics() self._emit({"value": self.selection})
[docs] def remove(self, dict_or_index: dict | int) -> None: """ In options mode: ``remove(index)``: remove an option index from the selection. In free mode: ``remove(key, list_index=-1)``: remove one item from the selection dict. """ if self._selection_options is not None: # options mode index = dict_or_index if not isinstance(index, Integral): raise TypeError( f"must provide integer index to append to selection " f"in 'options' mode, you passed: {dict_or_index!r}" ) if index in self._selected_indices: self._selected_indices.remove(index) self._update_all_graphics() self._emit({"value": self.selection}) else: d = dict_or_index keys = list(d.keys()) if any([k not in self._selection for k in keys]): raise KeyError( f"You provided keys that are not in the selection.\nkeys: {keys}\nselection: {self._selection}" ) for k in keys: for item in d[k]: self._selection[k].remove(item) if len(self._selection[k]) < 1: del self._selection[k] self._update_all_graphics() self._emit({"value": self.selection})
[docs] def clear(self) -> None: """Clear the selection (options mode: deselects all, free mode: clears all regions).""" if self._selection_options is not None: # options mode self._selected_indices = list() else: self._selection = dict() self._update_all_graphics() self._emit({"value": self.selection})
def _check_graphic(self, graphic) -> None: mat = getattr(graphic, "_material", None) if not isinstance(mat, HighlightableImageMaterial): raise TypeError( f"ImageHighlightSelector requires HighlightableImageMaterial, " f"got {type(mat).__name__}." ) def _create_mask_texture(self, mask: np.ndarray) -> pygfx.Texture: rows, cols = mask.shape texture = pygfx.Texture( size=(cols, rows, 1), # initialize with size, no local cpu buffer dim=2, format="r16uint", usage=wgpu.TextureUsage.COPY_DST, ) # send initialized data directly to GPU texture.send_data((0, 0, 0), mask) return texture def _create_mask(self, n_rows: int, n_cols: int) -> np.ndarray: """create uint16 mask array for the current selection""" mask = np.zeros((n_rows, n_cols), dtype=np.uint16) sel = ( self._selection_options if self._selection_options is not None else self._selection ) if "rows" in sel and "cols" in sel: if len(sel["rows"]) != len(sel["cols"]): raise ValueError( f"'rows' and 'cols' must have the same length when both given " f"({len(sel['rows'])} vs {len(sel['cols'])})" ) # 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 def _fill_lut(self) -> None: """Write current highlight colors into the LUT buffer.""" lut_buffer = self._lut_buffer.data lut_buffer[:] = 0.0 if self._selection_options is not None: n_placeholder = self._len_dict(self._selection_options) # reset all the options to the unselected placeholder color lut_buffer[:n_placeholder] = self._rgba( self._options_color, self._options_alpha ) n_sel = len(self._selected_indices) if n_sel > 0: current_lut = _build_lut( color=self._color, lut=self._lut, n=n_sel, lut_wrap=self._lut_wrap ) 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) if n > 0: current_lut = _build_lut( color=self._color, lut=self._lut, n=n, lut_wrap=self._lut_wrap ) current_lut[:, 3] *= self._alpha lut_buffer[:n] = current_lut self._lut_buffer.update_full() def _update_highlight_buffers(self, graphic) -> None: # Called once per graphic on add_graphic. Set selector buffers onto # the material. Subsequent graphics just get references to the same objects. material = graphic._material material._highlight_lut_buffer = self._lut_buffer if self._mask_texture is None: n_rows, n_cols = graphic.data.value.shape[:2] self._mask_texture = self._create_mask_texture( self._create_mask(n_rows, n_cols) ) self._fill_lut() material._highlight_mask_texture = self._mask_texture material.uniform_buffer.data["highlight_alpha"] = 1.0 material.uniform_buffer.update_range() def _update_all_graphics(self) -> None: if not self._graphics: return shapes = {g.data.value.shape[:2] for g in self._graphics} if len(shapes) > 1: raise ValueError( f"All targeted Image data must have the same shape, your images have shapes: {shapes}" ) n_rows, n_cols = self._graphics[0].data.value.shape[:2] mask = self._create_mask(n_rows, n_cols) # Re-create GPU texture if shape changed if self._mask_texture is None or self._mask_texture.size != (n_cols, n_rows, 1): self._mask_texture = self._create_mask_texture(mask) for g in self._graphics: g._material._highlight_mask_texture = self._mask_texture else: # just send the new data self._mask_texture.send_data((0, 0, 0), mask) self._fill_lut() # uniform_buffer is per-material and cannot be shared for g in self._graphics: g._material.uniform_buffer.data["highlight_alpha"] = 1.0 g._material.uniform_buffer.update_range() def _clear_highlight_buffers(self, graphic) -> None: # Restore the detached material to minimal self-owned placeholders # this is done when a graphic is removed from the selector mat = graphic._material mat._highlight_mask_texture = pygfx.Texture( np.zeros((1, 1), dtype=np.uint16), dim=2 ) mat._highlight_lut_buffer = pygfx.Buffer(np.zeros((1, 4), dtype=np.float32)) def __len__(self) -> int: if self._selection_options is not None: return len(self._selected_indices) return self._len_dict(self._selection) def __contains__(self, item: int | dict) -> bool: if self._selection_options is not None: return int(item) in self._selected_indices # check if a single row-col pair is in the selection if "rows" in item and "cols" in item: if ( item["rows"] in self._selection["rows"] and item["cols"] in self._selection["cols"] ): return True # check for basic membership if "rows" in item: return item["rows"] in self._selection["rows"] if "cols" in item: return item["cols"] in self._selection["col"] if "pixels" in item: return item["pixels"] in self._selection["pixels"] def __iter__(self): if self._selection_options is not None: return iter(self._selected_indices) return iter(self._selection.values()) def __repr__(self) -> str: if self._selection_options is not None: # options mode return ( f"ImageHighlightSelector\n" f"selected: {self._selected_indices}\n" f"options: {self._selection_options}\n" ) return f"ImageHighlightSelector\n" f"selection: {self._selection}, "