From c660721bb3d287d99a9bfaa08a6f832a099cb359 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 9 May 2026 04:44:15 -0400 Subject: [PATCH 01/14] use fully fledged async --- fastplotlib/widgets/nd_widget/_async.py | 109 +++------------ fastplotlib/widgets/nd_widget/_base.py | 132 +++++++++--------- fastplotlib/widgets/nd_widget/_index.py | 90 +++++++----- fastplotlib/widgets/nd_widget/_nd_image.py | 56 +++++--- .../nd_widget/_nd_positions/_nd_positions.py | 61 ++++---- fastplotlib/widgets/nd_widget/_nd_vectors.py | 61 ++++---- fastplotlib/widgets/nd_widget/_video.py | 4 +- 7 files changed, 237 insertions(+), 276 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_async.py b/fastplotlib/widgets/nd_widget/_async.py index 5aa24a65f..4fa1d44da 100644 --- a/fastplotlib/widgets/nd_widget/_async.py +++ b/fastplotlib/widgets/nd_widget/_async.py @@ -1,100 +1,23 @@ -from collections.abc import Generator -from concurrent.futures import Future +import asyncio +from concurrent.futures import Executor, ThreadPoolExecutor +from typing import Any, Callable, Coroutine -from ...utils import ArrayProtocol, FutureProtocol, CudaArrayProtocol, cuda_to_numpy +async def run_in_thread_pool( + executor: Executor, fn: Callable, *args, **kwargs +) -> Any: + """Submit ``fn(*args, **kwargs)`` to ``executor`` and await the result.""" + return await asyncio.wrap_future(executor.submit(fn, *args, **kwargs)) -class FutureArray(Future): - def __init__(self, shape, dtype, timeout: float = 1.0): - self._shape = shape - self._dtype = dtype - self._timeout = timeout - super().__init__() - - @property - def shape(self) -> tuple[int, ...]: - return self._shape - - @property - def ndim(self) -> int: - return len(self.shape) - - @property - def dtype(self) -> str: - return self._dtype - - def __getitem__(self, item) -> ArrayProtocol: - return self.result(self._timeout)[item] - - def __array__(self) -> ArrayProtocol: - return self.result(self._timeout) - - def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): - raise NotImplementedError - - def __array_function__(self, func, types, *args, **kwargs): - raise NotImplementedError - - -# inspired by https://www.dabeaz.com/coroutines/ -def start_coroutine(func): +def run_sync(coro: Coroutine) -> Any: """ - Starts coroutines for async arrays wrapped by NDProcessor. - Used by all NDGraphic.set_indices and NDGraphic._create_graphic. - - It also immediately starts coroutines unless block=False is provided. It handles all the triage of possible - sync vs. async (Future-like) objects. + Drive an ``async def`` coroutine to completion synchronously, in a helper thread. - The only time when block=False is when ReferenceIndex._render_indices uses it to loop through setting all - indices, and then collect and send the results back down to NDProcessor.get(). + Used by construction-time call sites (NDGraphic.__init__, data setter, property + setters) that cannot be made async without coloring the public API. + ``asyncio.run`` is dispatched to a helper thread so this never collides with a + loop already running on the calling thread (the rendercanvas loop, Jupyter, IDEs). """ - - def start( - self, *args, **kwargs - ) -> tuple[Generator, ArrayProtocol | CudaArrayProtocol | FutureProtocol] | None: - cr = func(self, *args, **kwargs) - try: - # begin coroutine - to_resolve: FutureProtocol | ArrayProtocol | CudaArrayProtocol = cr.send( - None - ) - except StopIteration: - # NDProcessor.get() has no `yield` expression, not async, nothing to return - return None - - block = kwargs.get("block", True) - timeout = kwargs.get("timeout", 1.0) - - if block: # resolve Future immediately - try: - if isinstance(to_resolve, FutureProtocol): - # array is async, resolve future and send - cr.send(to_resolve.result(timeout=timeout)) - elif isinstance(to_resolve, CudaArrayProtocol): - # array is on GPU, it is technically and on GPU, convert to numpy array on CPU - cr.send(cuda_to_numpy(to_resolve)) - else: - # not async, just send the array - cr.send(to_resolve) - except StopIteration: - pass - - else: # no block, probably resolving multiple futures simultaneously - if isinstance(to_resolve, FutureProtocol): - # data is async, return coroutine generator and future - # ReferenceIndex._render_indices() will manage them and wait to gather all futures - return cr, to_resolve - elif isinstance(to_resolve, CudaArrayProtocol): - # it is async technically, but it's a GPU array, ReferenceIndex._render_indices will manage it - return cr, to_resolve - else: - # not async, just send the array - try: - cr.send(to_resolve) - except ( - StopIteration - ): # has to be here because of the yield expression, i.e. it's a generator - pass - - return start + with ThreadPoolExecutor(max_workers=1) as ex: + return ex.submit(asyncio.run, coro).result() diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index 724f5fdd6..c129e4aba 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -1,4 +1,6 @@ -from collections.abc import Callable, Sequence, Generator +import asyncio +from collections.abc import Callable, Sequence +from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager import inspect from numbers import Real @@ -12,13 +14,10 @@ from ...layouts import Subplot from ...utils import ArrayProtocol, FutureProtocol, CudaArrayProtocol from ...graphics import Graphic +from ._async import run_in_thread_pool, run_sync # must take arguments: array-like, `axis`: int, `keepdims`: bool WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike] -# [YieldType, SendType, ReturnType] -AwaitedArray = Generator[ - FutureProtocol | ArrayProtocol | CudaArrayProtocol, ArrayProtocol, ArrayProtocol -] def identity(index: int) -> int: @@ -125,6 +124,18 @@ def __init__( self.window_order = window_order self.spatial_func = spatial_func + # window_funcs and spatial_func are dispatched here so they don't block + # the rendercanvas event loop. CUDA arrays bypass this and run inline, + # since GPU compute already overlaps with the loop and the user is + # expected to provide CUDA-aware funcs in that case. + self._executor = ThreadPoolExecutor( + max_workers=1, thread_name_prefix=f"ndp-{id(self):x}" + ) + + def close(self): + """Shut down the per-processor thread pool.""" + self._executor.shutdown(wait=False, cancel_futures=True) + @property def data(self) -> ArrayProtocol: """ @@ -436,11 +447,15 @@ def _get_slider_dims_indexer(self, indices: dict[str, Any]) -> dict[str, slice]: return indexer - def _apply_window_functions(self, windowed_array: ArrayProtocol) -> ArrayProtocol: + async def _apply_window_functions(self, windowed_array: ArrayProtocol) -> ArrayProtocol: """ apply window functions in the order specified by ``window_order``. + For numpy arrays each func is dispatched to the per-processor thread pool so it + does not block the rendercanvas event loop. CUDA arrays are run inline (the user + is expected to supply CUDA-aware funcs in that case). + Parameters ---------- windowed_array: ArrayProtocol @@ -460,18 +475,22 @@ def _apply_window_functions(self, windowed_array: ArrayProtocol) -> ArrayProtoco continue func, _ = self.window_funcs[dim] + axis = self.dims.index(dim) # ``keepdims=True`` is critical, any "collapsed" dims will be of size ``1``. # Ex: if `array` is of shape [10, 512, 512] and we applied the np.mean() window func on the first dim # ``keepdims`` means the resultant shape is [1, 512, 512] and NOT [512, 512] # this is necessary for applying window functions on multiple dims separately and so that the # dims names correspond after all the window funcs are applied. - windowed_array = func( - windowed_array, axis=self.dims.index(dim), keepdims=True - ) + if isinstance(windowed_array, CudaArrayProtocol): + windowed_array = func(windowed_array, axis=axis, keepdims=True) + else: + windowed_array = await run_in_thread_pool( + self._executor, func, windowed_array, axis=axis, keepdims=True + ) return windowed_array - def get_window_output(self, indices: dict[str, Any]) -> AwaitedArray: + async def get_window_output(self, indices: dict[str, Any]) -> ArrayProtocol: """ Applies any window functions and returns squeezed sliced array transposed in the order of the given spatial dims @@ -484,14 +503,15 @@ def get_window_output(self, indices: dict[str, Any]) -> AwaitedArray: """ # windowed slice if user set any window funcs - windowed_slice = yield from self._get_raw_data_slice(indices) + windowed_slice = await self._get_raw_data_slice(indices) - # convert to numpy array - windowed_slice = np.asarray(windowed_slice) + # convert to numpy array; CUDA arrays pass through and are converted at the end of the pipeline + if not isinstance(windowed_slice, CudaArrayProtocol): + windowed_slice = np.asarray(windowed_slice) # apply window funcs if len(self.slider_dims) > 0: - windowed_slice = self._apply_window_functions(windowed_slice) + windowed_slice = await self._apply_window_functions(windowed_slice) # squeeze out all slider dims which should now be size 1 # set(dims) - set(spatial_dims) since some spatial dims can also be slider, so get only pure non-spatial dims @@ -512,27 +532,29 @@ def get_window_output(self, indices: dict[str, Any]) -> AwaitedArray: return windowed_slice.transpose(spatial_dims_int) - def _get_raw_data_slice(self, indices: dict[str, Any]) -> AwaitedArray: + async def _get_raw_data_slice(self, indices: dict[str, Any]) -> ArrayProtocol: """ Base implementation to get the raw data slice from the wrapped array. - Always yields to support async getters. + + Awaits any ``FutureProtocol`` returned by the underlying loader. CUDA arrays + are returned as-is and converted to numpy at the end of the pipeline. """ if len(self.slider_dims) > 0: indexer = self._get_slider_dims_indexer(indices) # get the data slice w.r.t. the desired windows - # yield so this is async if the underlying array returns a FutureArray-like - # we convert to a numpy array outside, not here, since that resolves the Future index_tuple = tuple(indexer.get(dim, slice(None)) for dim in self.dims) - raw_slice = yield self.data[index_tuple] + raw_slice = self.data[index_tuple] else: # return everything directly # request a slice of everything with [:] so that any data fetching, compute, etc. is actually done - raw_slice = yield self.data[:] + raw_slice = self.data[:] + if isinstance(raw_slice, FutureProtocol): + return await asyncio.wrap_future(raw_slice) return raw_slice - def get(self, indices: dict[str, Any]) -> AwaitedArray | ArrayProtocol: + async def get(self, indices: dict[str, Any]) -> ArrayProtocol: raise NotImplementedError # TODO: html and pretty text repr # @@ -583,18 +605,16 @@ def __init__( self._name = name self._graphic: Graphic | None = None - # used to indicate that the NDGraphic should ignore any requests to update the indices + # used to indicate that the NDGraphic should ignore any requests to update the indices. # used by block_indices_ctx context manager, usecase is when the LinearSelector on timeseries # NDGraphic changes the selection, it shouldn't change the graphic that it is on top of! Would - # also cause recursion - # It is also used by the @block_reentrance decorator which is on the ``NDGraphic.indices`` property setter - # this is also to block recursion + # also cause recursion. ReferenceIndex._render_indices checks this flag at scheduling time. self._block_indices = False # user settable bool to make the graphic unresponsive to change in the ReferenceIndex self._pause = False - def _create_graphic(self): + async def _create_graphic(self): raise NotImplementedError @property @@ -623,19 +643,22 @@ def graphic(self) -> Graphic: def indices(self) -> dict[str, Any]: raise NotImplementedError - def set_indices( - self, indices: dict[str, Any], block: bool = True, timeout: float = 1.0 + async def _set_indices_( + self, indices: dict[str, Any], should_write: Callable[[], bool] | None = None ): - pass - - def _get_data_slice(self, indices): - """gets current data slice from NDProcessor, resolves Futures if necessary""" - data_slice = self.processor.get(indices) + """ + Get the data slice for ``indices`` from the processor and write it to the graphic. - if isinstance(data_slice, Generator): - data_slice = yield from data_slice + Semi-private: only ``ReferenceIndex`` should call this. Construction-time call + sites use :func:`run_sync` to drive it synchronously. - return data_slice + ``should_write`` is checked right before the write. If supplied and it returns + False, the data slice is dropped. ``ReferenceIndex`` uses this to drop stale + results when a newer tick has superseded this one between the last ``await`` + and the graphic write (asyncio cancellation does not fire after the final + ``await`` of a task, so a tick check is required here). + """ + pass # aliases for easier access to processor properties @property @@ -655,10 +678,10 @@ def data(self, data: Any): self._subplot.delete_graphic(self.graphic) self._graphic = None - self._create_graphic() + run_sync(self._create_graphic()) # force a render - self.set_indices(self.indices) + run_sync(self._set_indices_(self.indices)) @property def shape(self) -> dict[str, int]: @@ -697,7 +720,7 @@ def slider_dim_transforms( """get or set the slider_dim_transforms, see docstring for details""" self.processor.slider_dim_transforms = maps # force a render - self.set_indices(self.indices) + run_sync(self._set_indices_(self.indices)) @property def window_funcs( @@ -716,7 +739,7 @@ def window_funcs( ): self.processor.window_funcs = window_funcs # force a render - self.set_indices(self.indices) + run_sync(self._set_indices_(self.indices)) @property def window_order(self) -> tuple[str, ...]: @@ -727,7 +750,7 @@ def window_order(self) -> tuple[str, ...]: def window_order(self, order: tuple[str] | None): self.processor.window_order = order # force a render - self.set_indices(self.indices) + run_sync(self._set_indices_(self.indices)) @property def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: @@ -741,7 +764,7 @@ def spatial_func( """get or set the spatial_func, see docstring for details""" self.processor.spatial_func = func # force a render - self.set_indices(self.indices) + run_sync(self._set_indices_(self.indices)) # def _repr_text_(self) -> str: # return ndg_fmt_text(self) @@ -777,28 +800,3 @@ def block_indices_ctx(ndgraphic: NDGraphic): ndgraphic._block_indices = False -def block_reentrance(setter): - # decorator to block re-entrance of indices setter - def set_indices_wrapper(self: NDGraphic, *args, **kwargs): - """ - wraps NDGraphic.indices - - self: NDGraphic instance - - new_indices: new indices to set - """ - # set_value is already in the middle of an execution, block re-entrance - if self._block_indices: - return - try: - # block re-execution of set_value until it has *fully* finished executing - self._block_indices = True - return setter(self, *args, **kwargs) - except Exception as exc: - # raise original exception - raise exc # set_value has raised. The line above and the lines 2+ steps below are probably more relevant! - finally: - # set_value has finished executing, now allow future executions - self._block_indices = False - - return set_indices_wrapper diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index 228f3a73f..a05734c3c 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -1,7 +1,6 @@ from __future__ import annotations -from collections.abc import Generator -from concurrent.futures import wait +import asyncio from dataclasses import dataclass from numbers import Number from typing import Sequence, Any, Callable @@ -10,8 +9,10 @@ if TYPE_CHECKING: from ._ndwidget import NDWidget + from ._base import NDGraphic -from ...utils import FutureProtocol, CudaArrayProtocol, cuda_to_numpy +from ...utils import loop as _loop +from ._async import run_sync class RangeContinuous: @@ -205,6 +206,12 @@ def __init__( self._ndwidgets: list[NDWidget] = list() + # render-request bookkeeping. Each new indices update increments _tick. Any in-flight + # asyncio.Task per graphic is cancelled and replaced when a newer tick arrives, and + # _set_indices_ checks the tick right before writing to drop stale results. + self._tick: int = 0 + self._awaiting: dict[NDGraphic, asyncio.Task] = dict() + @property def ref_ranges(self) -> dict[str, RangeContinuous | RangeDiscrete]: return self._ref_ranges @@ -238,44 +245,57 @@ def _clamp(self, dim, value): return value def _render_indices(self): - pending_futures = list() - pending_cuda = list() + """ + Schedule a render for every affected NDGraphic via the rendercanvas event loop. + + Each call increments ``_tick`` and cancels any in-flight task per graphic, so a + rapid slider drag drops queued window_func/spatial_func work and never writes a + stale frame after a fresh one. Falls back to synchronous drain when no loop is + available yet (figure not shown). + """ + self._tick += 1 + tick = self._tick + + # cancel any prior in-flight tasks. Future submissions on the per-processor + # ThreadPoolExecutor that haven't started yet will be cancelled via asyncio's + # propagation through asyncio.wrap_future. Already-running submissions complete + # but their results are dropped by the should_write tick check. + for task in self._awaiting.values(): + task.cancel() + self._awaiting.clear() for ndw in self._ndwidgets: for g in ndw.ndgraphics: - if g.data is None or g.pause: + if g.data is None or g.pause or g._block_indices: continue # only provide slider indices to the graphic indices = {d: self._indices[d] for d in g.processor.slider_dims} - to_resolve: None | tuple[Generator, FutureProtocol] = g.set_indices(indices, block=False) - - if to_resolve is not None: - if isinstance(to_resolve[1], FutureProtocol): - # it's a future that we need to resolve - pending_futures.append(to_resolve) - elif isinstance(to_resolve[1], CudaArrayProtocol): - pending_cuda.append(to_resolve) - - if not pending_futures and not pending_cuda: - # no futures or gpu arrays to resolve, everything is sync - return - - # resolve futures - wait([future for cr, future in pending_futures], timeout=2) - - for cr, future in pending_futures: - try: - cr.send(future.result()) - except StopIteration: - pass - - # resolve GPU arrays - for cr, gpu_arr in pending_cuda: - try: - arr = cuda_to_numpy(gpu_arr) - cr.send(arr) - except StopIteration: - pass + + try: + asyncio.get_running_loop() + except RuntimeError: + # no running loop (figure not shown yet): drain synchronously so + # construction-time programmatic ref_index updates still take effect. + run_sync(g._set_indices_(indices)) + continue + + _loop.add_task(self._render_request, g, indices, tick, name="ndw-render") + + async def _render_request( + self, graphic: "NDGraphic", indices: dict[str, Any], tick: int + ): + """Run the data pipeline for one graphic and write the result if still current.""" + self._awaiting[graphic] = asyncio.current_task() + try: + await graphic._set_indices_( + indices, should_write=lambda: self._tick == tick + ) + except asyncio.CancelledError: + pass + finally: + # drop self from _awaiting if still there (may have been overwritten by a newer tick) + if self._awaiting.get(graphic) is asyncio.current_task(): + del self._awaiting[graphic] def __getitem__(self, dim): self._check_has_dim(dim) diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index 6463eba88..836156547 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -1,22 +1,28 @@ -from collections.abc import Sequence, Generator +import asyncio +from collections.abc import Sequence from typing import Callable, Any, Literal import numpy as np from numpy.typing import ArrayLike from ...layouts import Subplot -from ...utils import subsample_array, ARRAY_LIKE_ATTRS, ArrayProtocol, enums +from ...utils import ( + subsample_array, + ARRAY_LIKE_ATTRS, + ArrayProtocol, + CudaArrayProtocol, + cuda_to_numpy, + enums, +) from ...graphics import ImageGraphic, ImageYUVGraphic, ImageVolumeGraphic from ...tools import HistogramLUTTool from ._base import ( NDProcessor, NDGraphic, WindowFuncCallable, - block_reentrance, - AwaitedArray, ) from ._index import ReferenceIndex -from ._async import start_coroutine +from ._async import run_in_thread_pool, run_sync class NDImageProcessor(NDProcessor): @@ -217,7 +223,7 @@ def histogram(self) -> tuple[np.ndarray, np.ndarray] | None: """ return self._histogram - def get(self, indices: dict[str, Any]) -> AwaitedArray: + async def get(self, indices: dict[str, Any]) -> ArrayProtocol: """ Get the data at the given index, process data through the window functions. @@ -232,15 +238,22 @@ def get(self, indices: dict[str, Any]) -> AwaitedArray: """ # this will be squeezed output, with dims in the order of the user set spatial dims - window_output = yield from self.get_window_output(indices) + window_output = await self.get_window_output(indices) - # apply spatial_func + # apply spatial_func; CUDA arrays run inline, numpy goes through the thread pool if self.spatial_func is not None: - spatial_out = self._spatial_func(window_output) - if spatial_out.ndim != len(self.spatial_dims): + if isinstance(window_output, CudaArrayProtocol): + window_output = self._spatial_func(window_output) + else: + window_output = await run_in_thread_pool( + self._executor, self._spatial_func, window_output + ) + if window_output.ndim != len(self.spatial_dims): raise ValueError - return spatial_out + # final CUDA -> numpy conversion at the end of the pipeline + if isinstance(window_output, CudaArrayProtocol): + window_output = await asyncio.to_thread(cuda_to_numpy, window_output) return window_output @@ -389,7 +402,7 @@ def __init__( self._histogram_widget: HistogramLUTTool | None = None # create a graphic - self._create_graphic() + run_sync(self._create_graphic()) @property def processor(self) -> NDImageProcessor: @@ -403,8 +416,7 @@ def graphic( """Underlying Graphic object used to display the current data slice""" return self._graphic - @start_coroutine - def _create_graphic(self): + async def _create_graphic(self): # Creates an ``ImageGraphic`` or ``ImageVolumeGraphic`` based on the number of spatial dims, # adds it to the subplot, and resets the camera and histogram. @@ -432,8 +444,7 @@ def _create_graphic(self): # get the data slice for this index # this will only have the dims specified by ``spatial_dims`` - - data_slice = yield from self._get_data_slice(self.indices) + data_slice = await self.processor.get(self.indices) # create the new graphic new_graphic = cls( @@ -540,20 +551,19 @@ def spatial_dims(self, dims: tuple[str, str] | tuple[str, str, str]): self.processor.spatial_dims = dims # shape has probably changed, recreate graphic - self._create_graphic() + run_sync(self._create_graphic()) @property def indices(self) -> dict[str, Any]: """get or set the indices, managed by the ReferenceIndex, users usually don't want to set this manually""" return {d: self._ref_index[d] for d in self.processor.slider_dims} - @block_reentrance - @start_coroutine - def set_indices( - self, indices: dict[str, Any], block: bool = True, timeout: float = 1.0 + async def _set_indices_( + self, indices: dict[str, Any], should_write: Callable[[], bool] | None = None ): - data_slice = yield from self._get_data_slice(indices) - + data_slice = await self.processor.get(indices) + if should_write is not None and not should_write(): + return self.graphic.data = data_slice @property diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index 61e3c97d2..7f3d9bc8a 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -1,4 +1,5 @@ -from collections.abc import Callable, Hashable, Sequence, Generator +import asyncio +from collections.abc import Callable, Hashable, Sequence from functools import partial from typing import Literal, Any, Type from warnings import warn @@ -24,12 +25,11 @@ NDProcessor, NDGraphic, WindowFuncCallable, - block_reentrance, block_indices_ctx, ) -from ....utils import ArrayProtocol, FutureProtocol, CudaArrayProtocol +from ....utils import ArrayProtocol, CudaArrayProtocol, cuda_to_numpy from .._index import ReferenceIndex -from .._async import start_coroutine +from .._async import run_in_thread_pool, run_sync # types for the other features FeatureCallable = Callable[[np.ndarray, slice], np.ndarray] @@ -37,12 +37,6 @@ MarkersType = Sequence[str] | np.ndarray | FeatureCallable | None SizesType = Sequence[float] | np.ndarray | FeatureCallable | None -AwaitedPositionData = Generator[ - FutureProtocol | ArrayProtocol | CudaArrayProtocol, - ArrayProtocol, - dict[str, ArrayProtocol], -] - def default_cmap_transform_each(p: int, data_slice: np.ndarray, s: slice): # create a cmap transform based on the `p` dim size @@ -526,7 +520,7 @@ def _get_other_features( return other - def get(self, indices: dict[str, Any]) -> AwaitedPositionData: + async def get(self, indices: dict[str, Any]) -> dict[str, ArrayProtocol]: """ slices through all slider dims and outputs an array that can be used to set graphic data @@ -534,7 +528,7 @@ def get(self, indices: dict[str, Any]) -> AwaitedPositionData: index for each dimension. Slices are not allowed, therefore __getitem__ is not suitable here. """ # already squeezed and in the correct spatial_dims order - window_output = yield from self.get_window_output(indices) + window_output = await self.get_window_output(indices) # get slice obj for display window dw_slice = self._get_dw_slice(indices) @@ -545,9 +539,19 @@ def get(self, indices: dict[str, Any]) -> AwaitedPositionData: # p_dims is dim 1 graphic_data = window_output[:, dw_slice] - data = self._finalize(graphic_data) + # _finalize runs the user's datapoints_window_func and spatial_func. + # CUDA arrays run inline, numpy goes through the thread pool. + if isinstance(graphic_data, CudaArrayProtocol): + data = self._finalize(graphic_data) + else: + data = await run_in_thread_pool(self._executor, self._finalize, graphic_data) + other = self._get_other_features(data, dw_slice) + # final CUDA -> numpy conversion at the end of the pipeline + if isinstance(data, CudaArrayProtocol): + data = await asyncio.to_thread(cuda_to_numpy, data) + return { "data": data, **other, @@ -699,7 +703,7 @@ def __init__( else: self._linear_selector = None - self._create_graphic() + run_sync(self._create_graphic()) @property def processor(self) -> NDPositionsProcessor: @@ -742,7 +746,7 @@ def graphic_type(self, graphic_type): self._subplot.delete_graphic(self._graphic) self._graphic_type = graphic_type - self._create_graphic() + run_sync(self._create_graphic()) @property def spatial_dims(self) -> tuple[str, str, str]: @@ -752,21 +756,21 @@ def spatial_dims(self) -> tuple[str, str, str]: def spatial_dims(self, dims: tuple[str, str, str]): self.processor.spatial_dims = dims # force re-render - self.set_indices(self.indices) + run_sync(self._set_indices_(self.indices)) @property def indices(self) -> dict[Hashable, Any]: return {d: self._ref_index[d] for d in self.processor.slider_dims} - @block_reentrance - @start_coroutine - def set_indices( - self, indices: dict[Hashable, Any], block: bool = True, timeout: float = 1.0 + async def _set_indices_( + self, indices: dict[Hashable, Any], should_write: Callable[[], bool] | None = None ): if self.data is None: return - new_features = yield from self._get_data_slice(indices) + new_features = await self.processor.get(indices) + if should_write is not None and not should_write(): + return data_slice = new_features["data"] @@ -846,12 +850,11 @@ def _tooltip_handler(self, graphic, pick_info): p_index = pick_info["vertex_index"] return self.processor.tooltip_format(n_index, p_index) - @start_coroutine - def _create_graphic(self): + async def _create_graphic(self): if self.data is None: return - new_features = yield from self._get_data_slice(self.indices) + new_features = await self.processor.get(self.indices) data_slice = new_features["data"] # store any cmap, sizes, thickness, etc. to assign to new graphic @@ -975,7 +978,7 @@ def display_window(self, dw: int | float | None): self.processor.display_window = dw # force re-render - self.set_indices(self.indices) + run_sync(self._set_indices_(self.indices)) @property def datapoints_window_func(self) -> tuple[Callable, str, int | float] | None: @@ -1049,7 +1052,7 @@ def cmap(self, new: str | None): self._graphic.cmap = new self._cmap = new # force a re-render - self.set_indices(self.indices) + run_sync(self._set_indices_(self.indices)) @property def cmap_each(self) -> np.ndarray[str] | None: @@ -1115,7 +1118,7 @@ def markers(self, new: str | None): self.graphic.markers = new self._markers = new # force a re-render - self.set_indices(self.indices) + run_sync(self._set_indices_(self.indices)) @property def sizes(self) -> float | Sequence[float] | None: @@ -1134,7 +1137,7 @@ def sizes(self, new: float | Sequence[float] | None): self.graphic.sizes = new self._sizes = new # force a re-render - self.set_indices(self.indices) + run_sync(self._set_indices_(self.indices)) @property def thickness(self) -> float | Sequence[float] | None: @@ -1153,4 +1156,4 @@ def thickness(self, new: float | Sequence[float] | None): self.graphic.thickness = new self._thickness = new # force a re-render - self.set_indices(self.indices) + run_sync(self._set_indices_(self.indices)) diff --git a/fastplotlib/widgets/nd_widget/_nd_vectors.py b/fastplotlib/widgets/nd_widget/_nd_vectors.py index 0b8e04725..bcdc7fcff 100644 --- a/fastplotlib/widgets/nd_widget/_nd_vectors.py +++ b/fastplotlib/widgets/nd_widget/_nd_vectors.py @@ -1,21 +1,26 @@ -from collections.abc import Sequence, Generator, Callable +import asyncio +from collections.abc import Sequence, Callable from typing import Any import numpy as np from numpy.typing import ArrayLike from ...layouts import Subplot -from ...utils import subsample_array, ARRAY_LIKE_ATTRS, ArrayProtocol +from ...utils import ( + subsample_array, + ARRAY_LIKE_ATTRS, + ArrayProtocol, + CudaArrayProtocol, + cuda_to_numpy, +) from ...graphics import VectorsGraphic from ._base import ( NDProcessor, NDGraphic, WindowFuncCallable, - block_reentrance, - AwaitedArray, ) from ._index import ReferenceIndex -from ._async import start_coroutine +from ._async import run_in_thread_pool, run_sync class NDVectorsProcessor(NDProcessor): @@ -137,7 +142,7 @@ def spatial_dims(self, sdims: tuple[str, str, str]): f"Spatial dimensions must haves shape (num_vecs, 2, [2 or 3]) you passed an array of shape {data.shape}" ) - def get(self, indices: dict[str, Any]) -> AwaitedArray: + async def get(self, indices: dict[str, Any]) -> ArrayProtocol: """ Get the data at the given index, process data through the window functions. @@ -152,15 +157,22 @@ def get(self, indices: dict[str, Any]) -> AwaitedArray: """ # this will be squeezed output, with dims in the order of the user set spatial dims - window_output = yield from self.get_window_output(indices) + window_output = await self.get_window_output(indices) - # apply spatial_func + # apply spatial_func; CUDA arrays run inline, numpy goes through the thread pool if self.spatial_func is not None: - spatial_out = self._spatial_func(window_output) - if spatial_out.ndim != len(self.spatial_dims): + if isinstance(window_output, CudaArrayProtocol): + window_output = self._spatial_func(window_output) + else: + window_output = await run_in_thread_pool( + self._executor, self._spatial_func, window_output + ) + if window_output.ndim != len(self.spatial_dims): raise ValueError - return spatial_out + # final CUDA -> numpy conversion at the end of the pipeline + if isinstance(window_output, CudaArrayProtocol): + window_output = await asyncio.to_thread(cuda_to_numpy, window_output) return window_output @@ -264,7 +276,7 @@ def __init__( self._graphic_kwargs = graphic_kwargs # create a graphic - self._create_graphic() + run_sync(self._create_graphic()) @property def processor(self) -> NDVectorsProcessor: @@ -278,8 +290,7 @@ def graphic( """Underlying Graphic object used to display the current data slice""" return self._graphic - @start_coroutine - def _create_graphic(self): + async def _create_graphic(self): # Creates an ``ImageGraphic`` or ``ImageVolumeGraphic`` based on the number of spatial dims, # adds it to the subplot, and resets the camera and histogram. @@ -289,8 +300,7 @@ def _create_graphic(self): # get the data slice for this index # this will only have the dims specified by ``spatial_dims`` - - data_slice = yield from self._get_data_slice(self.indices) + data_slice = await self.processor.get(self.indices) old_graphic = self._graphic # check if we are replacing a graphic @@ -320,25 +330,22 @@ def spatial_dims(self, dims: tuple[str, str, str]): self.processor.spatial_dims = dims # shape has probably changed, recreate graphic - self._create_graphic() + run_sync(self._create_graphic()) @property def indices(self) -> dict[str, Any]: """get or set the indices, managed by the ReferenceIndex, users usually don't want to set this manually""" return {d: self._ref_index[d] for d in self.processor.slider_dims} - @block_reentrance - @start_coroutine - def set_indices( - self, indices: dict[str, Any], block: bool = True, timeout: float = 1.0 + async def _set_indices_( + self, indices: dict[str, Any], should_write: Callable[[], bool] | None = None ): - data_slice = yield from self._get_data_slice(indices) - - positions = data_slice[:, 0] - directions = data_slice[:, 1] + data_slice = await self.processor.get(indices) + if should_write is not None and not should_write(): + return - self.graphic.positions = positions - self.graphic.directions = directions + self.graphic.positions = data_slice[:, 0] + self.graphic.directions = data_slice[:, 1] @property def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: diff --git a/fastplotlib/widgets/nd_widget/_video.py b/fastplotlib/widgets/nd_widget/_video.py index d1db1d7d8..23e8cd6e9 100644 --- a/fastplotlib/widgets/nd_widget/_video.py +++ b/fastplotlib/widgets/nd_widget/_video.py @@ -5,7 +5,7 @@ class VideoProcessor(NDImageProcessor): - def get_window_output(self, indices: dict[str, Any]): + async def get_window_output(self, indices: dict[str, Any]): """ Applies any window functions and returns squeezed sliced array transposed in the order of the given spatial dims @@ -18,7 +18,7 @@ def get_window_output(self, indices: dict[str, Any]): """ # windowed slice if user set any window funcs - windowed_slice = yield from self._get_raw_data_slice(indices) + windowed_slice = await self._get_raw_data_slice(indices) if isinstance(windowed_slice, (tuple, list)): return tuple(a.squeeze() for a in windowed_slice) From c0affe5ceb81b4c7511bd7c00d771ab0031eb338 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 9 May 2026 19:46:06 -0400 Subject: [PATCH 02/14] better throttling --- fastplotlib/widgets/nd_widget/_base.py | 10 +- fastplotlib/widgets/nd_widget/_index.py | 98 ++++++++----------- fastplotlib/widgets/nd_widget/_nd_image.py | 9 +- .../nd_widget/_nd_positions/_nd_positions.py | 13 +-- fastplotlib/widgets/nd_widget/_nd_vectors.py | 7 +- fastplotlib/widgets/nd_widget/_ui.py | 16 +-- 6 files changed, 53 insertions(+), 100 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index c129e4aba..a22ad4f6b 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -643,20 +643,12 @@ def graphic(self) -> Graphic: def indices(self) -> dict[str, Any]: raise NotImplementedError - async def _set_indices_( - self, indices: dict[str, Any], should_write: Callable[[], bool] | None = None - ): + async def _set_indices_(self, indices: dict[str, Any]): """ Get the data slice for ``indices`` from the processor and write it to the graphic. Semi-private: only ``ReferenceIndex`` should call this. Construction-time call sites use :func:`run_sync` to drive it synchronously. - - ``should_write`` is checked right before the write. If supplied and it returns - False, the data slice is dropped. ``ReferenceIndex`` uses this to drop stale - results when a newer tick has superseded this one between the last ``await`` - and the graphic write (asyncio cancellation does not fire after the final - ``await`` of a task, so a tick check is required here). """ pass diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index a05734c3c..3b734a27d 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -59,8 +59,6 @@ def __init__(self, start: int | float, stop: int | float, step: int | float): self._stop = stop self._step = step - self._throttle = 0.2 - @property def start(self) -> int | float: """get or set the start boundary of the reference range""" @@ -84,17 +82,6 @@ def step(self) -> int | float: """get or set the step size of the range, only used for UI elements""" return self._step - @property - def throttle(self) -> float: - """get or set throttle value in seconds. Used for throttling UI sliders""" - return self._throttle - - @throttle.setter - def throttle(self, val: float): - if val < 0: - raise ValueError("throttle value must be >= 0.0") - self._throttle = val - @property def size(self) -> int | float: """the size of the reference range""" @@ -177,8 +164,8 @@ def __init__( Single shared time axis: ri = ReferenceIndex(ref_ranges={"time": (0, 1000, 1), "depth": (15, 35, 0.5)}) - ri["time"] = 500 # update one dim and re-render - ri.set({"time": 500, "depth": 10}) # update several dims atomically + ri.set_dim_index("time", 500) # update one dim and re-render + ri.set({"time": 500, "depth": 10}) # update several dims atomically Two independent time axes for data from two different recording sessions: @@ -206,10 +193,8 @@ def __init__( self._ndwidgets: list[NDWidget] = list() - # render-request bookkeeping. Each new indices update increments _tick. Any in-flight - # asyncio.Task per graphic is cancelled and replaced when a newer tick arrives, and - # _set_indices_ checks the tick right before writing to drop stale results. - self._tick: int = 0 + # tracks in-flight throttled render tasks so they can be cancelled when a newer + # slider position arrives before the previous one has finished loading self._awaiting: dict[NDGraphic, asyncio.Task] = dict() @property @@ -228,11 +213,32 @@ def _add_ndwidget_(self, ndw: NDWidget): self._ndwidgets.append(ndw) - def set(self, indices: dict[str, Any]): + def set(self, indices: dict[str, Any], throttle: bool = False): for dim, value in indices.items(): self._indices[dim] = self._clamp(dim, value) - self._render_indices() + self._render_indices(throttle=throttle) + self._indices_changed() + + def set_dim_index(self, dim: str, index, throttle: bool = False): + """ + Set the index for a single dimension and trigger a render. + + Parameters + ---------- + dim : str + Dimension name. + index : int or float + New reference-space value for this dimension. + throttle : bool, default False + If True, cancel any in-flight render tasks before scheduling a new one. + Use this only for rapid-fire inputs such as an imgui slider drag where + intermediate positions are disposable. All other callers (play advance, + step buttons, LinearSelector, programmatic updates) should leave this False. + """ + self._check_has_dim(dim) + self._indices[dim] = self._clamp(dim, index) + self._render_indices(throttle=throttle) self._indices_changed() def _clamp(self, dim, value): @@ -244,70 +250,52 @@ def _clamp(self, dim, value): return value - def _render_indices(self): + def _render_indices(self, throttle: bool = False): """ Schedule a render for every affected NDGraphic via the rendercanvas event loop. - Each call increments ``_tick`` and cancels any in-flight task per graphic, so a - rapid slider drag drops queued window_func/spatial_func work and never writes a - stale frame after a fresh one. Falls back to synchronous drain when no loop is - available yet (figure not shown). + When ``throttle=True``, any in-flight tasks from a previous throttled call are + cancelled before new ones are scheduled, so rapid slider drags never queue up + stale window_func/spatial_func work. Falls back to a synchronous drain when no + event loop is running yet (figure not shown). """ - self._tick += 1 - tick = self._tick - - # cancel any prior in-flight tasks. Future submissions on the per-processor - # ThreadPoolExecutor that haven't started yet will be cancelled via asyncio's - # propagation through asyncio.wrap_future. Already-running submissions complete - # but their results are dropped by the should_write tick check. - for task in self._awaiting.values(): - task.cancel() - self._awaiting.clear() + if throttle: + for task in self._awaiting.values(): + task.cancel() + self._awaiting.clear() for ndw in self._ndwidgets: for g in ndw.ndgraphics: if g.data is None or g.pause or g._block_indices: continue - # only provide slider indices to the graphic indices = {d: self._indices[d] for d in g.processor.slider_dims} try: asyncio.get_running_loop() except RuntimeError: - # no running loop (figure not shown yet): drain synchronously so - # construction-time programmatic ref_index updates still take effect. run_sync(g._set_indices_(indices)) continue - _loop.add_task(self._render_request, g, indices, tick, name="ndw-render") + _loop.add_task(self._render_request, g, indices, throttle, name="ndw-render") async def _render_request( - self, graphic: "NDGraphic", indices: dict[str, Any], tick: int + self, graphic: "NDGraphic", indices: dict[str, Any], throttle: bool ): - """Run the data pipeline for one graphic and write the result if still current.""" - self._awaiting[graphic] = asyncio.current_task() + """Run the data pipeline for one graphic and write the result.""" + if throttle: + self._awaiting[graphic] = asyncio.current_task() try: - await graphic._set_indices_( - indices, should_write=lambda: self._tick == tick - ) + await graphic._set_indices_(indices) except asyncio.CancelledError: pass finally: - # drop self from _awaiting if still there (may have been overwritten by a newer tick) - if self._awaiting.get(graphic) is asyncio.current_task(): + if throttle and self._awaiting.get(graphic) is asyncio.current_task(): del self._awaiting[graphic] def __getitem__(self, dim): self._check_has_dim(dim) return self._indices[dim] - def __setitem__(self, dim, value): - self._check_has_dim(dim) - # set index for given dim and render - self._indices[dim] = self._clamp(dim, value) - self._render_indices() - self._indices_changed() - def _check_has_dim(self, dim): if dim not in self.dims: raise KeyError( diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index 836156547..e01f97298 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -558,13 +558,8 @@ def indices(self) -> dict[str, Any]: """get or set the indices, managed by the ReferenceIndex, users usually don't want to set this manually""" return {d: self._ref_index[d] for d in self.processor.slider_dims} - async def _set_indices_( - self, indices: dict[str, Any], should_write: Callable[[], bool] | None = None - ): - data_slice = await self.processor.get(indices) - if should_write is not None and not should_write(): - return - self.graphic.data = data_slice + async def _set_indices_(self, indices: dict[str, Any]): + self.graphic.data = await self.processor.get(indices) @property def compute_histogram(self) -> bool: diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index 7f3d9bc8a..920cd316c 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -762,16 +762,11 @@ def spatial_dims(self, dims: tuple[str, str, str]): def indices(self) -> dict[Hashable, Any]: return {d: self._ref_index[d] for d in self.processor.slider_dims} - async def _set_indices_( - self, indices: dict[Hashable, Any], should_write: Callable[[], bool] | None = None - ): + async def _set_indices_(self, indices: dict[Hashable, Any]): if self.data is None: return new_features = await self.processor.get(indices) - if should_write is not None and not should_write(): - return - data_slice = new_features["data"] # TODO: set other graphic features, colors, sizes, markers, etc. @@ -840,8 +835,7 @@ async def _set_indices_( def _linear_selector_handler(self, ev): with block_indices_ctx(self): - # linear selector always acts on the `p` dim - self._ref_index[self.processor.spatial_dims[1]] = ev.info["value"] + self._ref_index.set_dim_index(self.processor.spatial_dims[1], ev.info["value"]) def _tooltip_handler(self, graphic, pick_info): if isinstance(self.graphic, (LineCollection, ScatterCollection)): @@ -1030,8 +1024,7 @@ def _update_from_view_range(self): return self.processor.display_window = new_width - # set the `p` dim on the global index vector - self._ref_index[self.processor.spatial_dims[1]] = new_index + self._ref_index.set_dim_index(self.processor.spatial_dims[1], new_index) @property def cmap(self) -> str | None: diff --git a/fastplotlib/widgets/nd_widget/_nd_vectors.py b/fastplotlib/widgets/nd_widget/_nd_vectors.py index bcdc7fcff..a102eb552 100644 --- a/fastplotlib/widgets/nd_widget/_nd_vectors.py +++ b/fastplotlib/widgets/nd_widget/_nd_vectors.py @@ -337,13 +337,8 @@ def indices(self) -> dict[str, Any]: """get or set the indices, managed by the ReferenceIndex, users usually don't want to set this manually""" return {d: self._ref_index[d] for d in self.processor.slider_dims} - async def _set_indices_( - self, indices: dict[str, Any], should_write: Callable[[], bool] | None = None - ): + async def _set_indices_(self, indices: dict[str, Any]): data_slice = await self.processor.get(indices) - if should_write is not None and not should_write(): - return - self.graphic.positions = data_slice[:, 0] self.graphic.directions = data_slice[:, 1] diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py index 7f3a5f98e..f703dd1fa 100644 --- a/fastplotlib/widgets/nd_widget/_ui.py +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -53,9 +53,6 @@ def __init__(self, figure, size, ndwidget): # loop playback self._loop = {dim: False for dim in ref_ranges.keys()} - # last time the slider was moved, used for throttling - self._last_slider_movement: dict[str, float] = dict() - # auto-plays the ImageWidget's left-most dimension in docs galleries if "DOCS_BUILD" in os.environ.keys(): if os.environ["DOCS_BUILD"] == "1": @@ -72,7 +69,7 @@ def _set_index(self, dim, index): index = self._ndwidget.ranges[dim].stop self._playing[dim] = False - self._ndwidget.indices[dim] = index + self._ndwidget.indices.set_dim_index(dim, index) def update(self): now = perf_counter() @@ -117,7 +114,7 @@ def update(self): if imgui.button(label=fa.ICON_FA_STOP): self._playing[dim] = False self._last_frame_time[dim] = 0 - self._ndwidget.indices[dim] = rr.start + self._ndwidget.indices.set_dim_index(dim, rr.start) imgui.same_line() # loop checkbox @@ -160,15 +157,8 @@ def update(self): label=f"##{dim}", ) - # TODO: refactor all this stuff, make fully fledged UI if changed: - # apply throttling - if not dim in self._last_slider_movement: - self._last_slider_movement[dim] = 0.0 - - if now - self._last_slider_movement[dim] > rr.throttle: - self._ndwidget.indices[dim] = new_index - self._last_slider_movement[dim] = now + self._ndwidget.indices.set_dim_index(dim, new_index, throttle=True) elif imgui.is_item_hovered(): if imgui.is_key_pressed(imgui.Key.right_arrow): From 0490dcfe18ab35a1d928086ede46c3af9895f9ea Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 9 May 2026 19:49:32 -0400 Subject: [PATCH 03/14] torch.Tensor.tranpose() doesn't like tuples --- fastplotlib/widgets/nd_widget/_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index c129e4aba..3ebf52c42 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -530,7 +530,7 @@ async def get_window_output(self, indices: dict[str, Any]) -> ArrayProtocol: self.spatial_dims.index(d) for d in self.dims if d in self.spatial_dims ) - return windowed_slice.transpose(spatial_dims_int) + return windowed_slice.transpose(*spatial_dims_int) async def _get_raw_data_slice(self, indices: dict[str, Any]) -> ArrayProtocol: """ From 2d79624c3e1b8426e9a9681bdaff0bab5da56b22 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 9 May 2026 20:23:07 -0400 Subject: [PATCH 04/14] we need time-based throttling, but it can be gentler --- fastplotlib/widgets/nd_widget/_index.py | 42 ++++++++++++++++--------- fastplotlib/widgets/nd_widget/_ui.py | 7 ++++- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index 3b734a27d..894121038 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -58,6 +58,7 @@ def __init__(self, start: int | float, stop: int | float, step: int | float): self._start = start self._stop = stop self._step = step + self._throttle = 0.05 @property def start(self) -> int | float: @@ -82,6 +83,17 @@ def step(self) -> int | float: """get or set the step size of the range, only used for UI elements""" return self._step + @property + def throttle(self) -> float: + """get or set the minimum time in seconds between slider-drag renders""" + return self._throttle + + @throttle.setter + def throttle(self, val: float): + if val < 0: + raise ValueError("throttle value must be >= 0.0") + self._throttle = val + @property def size(self) -> int | float: """the size of the reference range""" @@ -213,14 +225,14 @@ def _add_ndwidget_(self, ndw: NDWidget): self._ndwidgets.append(ndw) - def set(self, indices: dict[str, Any], throttle: bool = False): + def set(self, indices: dict[str, Any], cancel_awaiting: bool = False): for dim, value in indices.items(): self._indices[dim] = self._clamp(dim, value) - self._render_indices(throttle=throttle) + self._render_indices(cancel_awaiting=cancel_awaiting) self._indices_changed() - def set_dim_index(self, dim: str, index, throttle: bool = False): + def set_dim_index(self, dim: str, index, cancel_awaiting: bool = False): """ Set the index for a single dimension and trigger a render. @@ -230,7 +242,7 @@ def set_dim_index(self, dim: str, index, throttle: bool = False): Dimension name. index : int or float New reference-space value for this dimension. - throttle : bool, default False + cancel_awaiting : bool, default False If True, cancel any in-flight render tasks before scheduling a new one. Use this only for rapid-fire inputs such as an imgui slider drag where intermediate positions are disposable. All other callers (play advance, @@ -238,7 +250,7 @@ def set_dim_index(self, dim: str, index, throttle: bool = False): """ self._check_has_dim(dim) self._indices[dim] = self._clamp(dim, index) - self._render_indices(throttle=throttle) + self._render_indices(cancel_awaiting=cancel_awaiting) self._indices_changed() def _clamp(self, dim, value): @@ -250,16 +262,16 @@ def _clamp(self, dim, value): return value - def _render_indices(self, throttle: bool = False): + def _render_indices(self, cancel_awaiting: bool = False): """ Schedule a render for every affected NDGraphic via the rendercanvas event loop. - When ``throttle=True``, any in-flight tasks from a previous throttled call are - cancelled before new ones are scheduled, so rapid slider drags never queue up - stale window_func/spatial_func work. Falls back to a synchronous drain when no - event loop is running yet (figure not shown). + When ``cancel_awaiting=True``, any in-flight tasks from a previous throttled + call are cancelled before new ones are scheduled, so rapid slider drags never + queue up stale window_func/spatial_func work. Falls back to a synchronous drain + when no event loop is running yet (figure not shown). """ - if throttle: + if cancel_awaiting: for task in self._awaiting.values(): task.cancel() self._awaiting.clear() @@ -276,20 +288,20 @@ def _render_indices(self, throttle: bool = False): run_sync(g._set_indices_(indices)) continue - _loop.add_task(self._render_request, g, indices, throttle, name="ndw-render") + _loop.add_task(self._render_request, g, indices, cancel_awaiting, name="ndw-render") async def _render_request( - self, graphic: "NDGraphic", indices: dict[str, Any], throttle: bool + self, graphic: "NDGraphic", indices: dict[str, Any], cancel_awaiting: bool ): """Run the data pipeline for one graphic and write the result.""" - if throttle: + if cancel_awaiting: self._awaiting[graphic] = asyncio.current_task() try: await graphic._set_indices_(indices) except asyncio.CancelledError: pass finally: - if throttle and self._awaiting.get(graphic) is asyncio.current_task(): + if cancel_awaiting and self._awaiting.get(graphic) is asyncio.current_task(): del self._awaiting[graphic] def __getitem__(self, dim): diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py index f703dd1fa..e63aa564f 100644 --- a/fastplotlib/widgets/nd_widget/_ui.py +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -53,6 +53,9 @@ def __init__(self, figure, size, ndwidget): # loop playback self._loop = {dim: False for dim in ref_ranges.keys()} + # last time the slider was moved per dim, used for time-based throttling + self._last_slider_movement: dict[str, float] = {dim: 0.0 for dim in ref_ranges.keys()} + # auto-plays the ImageWidget's left-most dimension in docs galleries if "DOCS_BUILD" in os.environ.keys(): if os.environ["DOCS_BUILD"] == "1": @@ -158,7 +161,9 @@ def update(self): ) if changed: - self._ndwidget.indices.set_dim_index(dim, new_index, throttle=True) + if now - self._last_slider_movement[dim] > rr.throttle: + self._ndwidget.indices.set_dim_index(dim, new_index, cancel_awaiting=True) + self._last_slider_movement[dim] = now elif imgui.is_item_hovered(): if imgui.is_key_pressed(imgui.Key.right_arrow): From 8c5258f7fd726b03ed95fd2123485d9a57e93964 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 10 May 2026 04:22:40 -0400 Subject: [PATCH 05/14] improvements --- fastplotlib/widgets/nd_widget/_async.py | 26 +++- fastplotlib/widgets/nd_widget/_base.py | 35 +++-- fastplotlib/widgets/nd_widget/_index.py | 137 +++++++++++++----- fastplotlib/widgets/nd_widget/_nd_image.py | 51 ++++--- .../nd_widget/_nd_positions/_nd_positions.py | 132 ++++++++++++----- fastplotlib/widgets/nd_widget/_nd_vectors.py | 27 ++-- fastplotlib/widgets/nd_widget/_ndw_subplot.py | 14 +- 7 files changed, 288 insertions(+), 134 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_async.py b/fastplotlib/widgets/nd_widget/_async.py index 4fa1d44da..72cd36b1f 100644 --- a/fastplotlib/widgets/nd_widget/_async.py +++ b/fastplotlib/widgets/nd_widget/_async.py @@ -1,13 +1,35 @@ import asyncio -from concurrent.futures import Executor, ThreadPoolExecutor +from concurrent.futures import Executor, Future, ThreadPoolExecutor from typing import Any, Callable, Coroutine +from rendercanvas.utils.asyncs import Event, detect_current_call_soon_threadsafe + + +async def wait_for_future(future: Future) -> Any: + """ + Await a ``concurrent.futures.Future`` from any rendercanvas-supported async + backend (asyncio for glfw/jupyter, the rendercanvas asyncadapter for qt/wx). + + ``asyncio.wrap_future`` cannot be used because the asyncadapter only + understands its own awaitables (see rendercanvas docs: "restrict your use + of async features to ``sleep`` and ``Event``"). We instead build the same + primitive on top of rendercanvas's cross-framework :class:`Event`, + signaled via the active loop's ``call_soon_threadsafe`` so the future's + done-callback (which runs on the executor thread) hands control back to + the event loop safely. + """ + event = Event() + call_soon_threadsafe = detect_current_call_soon_threadsafe() + future.add_done_callback(lambda f: call_soon_threadsafe(event.set)) + await event.wait() + return future.result() + async def run_in_thread_pool( executor: Executor, fn: Callable, *args, **kwargs ) -> Any: """Submit ``fn(*args, **kwargs)`` to ``executor`` and await the result.""" - return await asyncio.wrap_future(executor.submit(fn, *args, **kwargs)) + return await wait_for_future(executor.submit(fn, *args, **kwargs)) def run_sync(coro: Coroutine) -> Any: diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index 62adcfa62..bd7769017 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -1,4 +1,5 @@ -import asyncio +from __future__ import annotations + from collections.abc import Callable, Sequence from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager @@ -6,15 +7,17 @@ from numbers import Real from pprint import pformat import textwrap -from typing import Any +from typing import Any, TYPE_CHECKING import numpy as np from numpy.typing import ArrayLike -from ...layouts import Subplot from ...utils import ArrayProtocol, FutureProtocol, CudaArrayProtocol from ...graphics import Graphic -from ._async import run_in_thread_pool, run_sync +from ._async import run_in_thread_pool, run_sync, wait_for_future + +if TYPE_CHECKING: + from ._ndw_subplot import NDWSubplot # must take arguments: array-like, `axis`: int, `keepdims`: bool WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike] @@ -447,7 +450,9 @@ def _get_slider_dims_indexer(self, indices: dict[str, Any]) -> dict[str, slice]: return indexer - async def _apply_window_functions(self, windowed_array: ArrayProtocol) -> ArrayProtocol: + async def _apply_window_functions( + self, windowed_array: ArrayProtocol + ) -> ArrayProtocol: """ apply window functions in the order specified by ``window_order``. @@ -551,7 +556,7 @@ async def _get_raw_data_slice(self, indices: dict[str, Any]) -> ArrayProtocol: raw_slice = self.data[:] if isinstance(raw_slice, FutureProtocol): - return await asyncio.wrap_future(raw_slice) + return await wait_for_future(raw_slice) return raw_slice async def get(self, indices: dict[str, Any]) -> ArrayProtocol: @@ -598,10 +603,10 @@ def _repr_text_(self): class NDGraphic: def __init__( self, - subplot: Subplot, + nd_subplot: NDWSubplot, name: str | None, ): - self._subplot = subplot + self._nd_subplot = nd_subplot self._name = name self._graphic: Graphic | None = None @@ -667,7 +672,7 @@ def data(self, data: Any): # create a new graphic when data has changed if self.graphic is not None: # it is already None if NDGraphic was initialized with no data - self._subplot.delete_graphic(self.graphic) + self._nd_subplot.subplot.delete_graphic(self.graphic) self._graphic = None run_sync(self._create_graphic()) @@ -778,17 +783,17 @@ def _repr_text_(self): @contextmanager -def block_indices_ctx(ndgraphic: NDGraphic): +def block_indices_ctx(*ndgraphics: NDGraphic): """ - Context manager for pausing an NDGraphic from updating indices + Context manager for pausing NDGraphics from updating indices """ - ndgraphic._block_indices = True + for ndg in ndgraphics: + ndg._block_indices = True try: yield except Exception as e: raise e from None # indices setter has raised, the line above and the lines below are probably more relevant! finally: - ndgraphic._block_indices = False - - + for ndg in ndgraphics: + ndg._block_indices = False diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index 894121038..0f96fbe18 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -1,6 +1,7 @@ from __future__ import annotations -import asyncio +from collections import deque +from concurrent.futures import CancelledError from dataclasses import dataclass from numbers import Number from typing import Sequence, Any, Callable @@ -12,7 +13,6 @@ from ._base import NDGraphic from ...utils import loop as _loop -from ._async import run_sync class RangeContinuous: @@ -49,6 +49,7 @@ class RangeContinuous: RangeContinuous(start=0.0, stop=500.0, step=0.5) """ + def __init__(self, start: int | float, stop: int | float, step: int | float): if start >= stop: raise IndexError( @@ -205,9 +206,26 @@ def __init__( self._ndwidgets: list[NDWidget] = list() - # tracks in-flight throttled render tasks so they can be cancelled when a newer - # slider position arrives before the previous one has finished loading - self._awaiting: dict[NDGraphic, asyncio.Task] = dict() + # per-graphic render revision. Bumped on every ``cancel_awaiting=True`` + # call (always-latest inputs, such as slider drag). A scheduled render + # carries the revision it was created under and skips its final write + # if a newer revision has been requested in the meantime. + self._render_rev: dict[NDGraphic, int] = dict() + + # per-graphic queue of pending render requests for the serial drain + # path (``cancel_awaiting=False``: play, step, programmatic, + # LinearSelector). Each entry is ``(indices, rev)``. Drained by + # :meth:`_render_request` so the underlying data source only ever sees + # one read at a time. + self._render_request_queue: dict[ + NDGraphic, deque[tuple[dict[str, Any], int]] + ] = dict() + + # per-graphic flag: is :meth:`_render_request` currently draining + # ``_render_request_queue[g]``? Ensures only one drain coroutine is + # alive per graphic - subsequent ``cancel_awaiting=False`` calls just + # append to the queue. + self._render_request_active: dict[NDGraphic, bool] = dict() @property def ref_ranges(self) -> dict[str, RangeContinuous | RangeDiscrete]: @@ -264,45 +282,91 @@ def _clamp(self, dim, value): def _render_indices(self, cancel_awaiting: bool = False): """ - Schedule a render for every affected NDGraphic via the rendercanvas event loop. - - When ``cancel_awaiting=True``, any in-flight tasks from a previous throttled - call are cancelled before new ones are scheduled, so rapid slider drags never - queue up stale window_func/spatial_func work. Falls back to a synchronous drain - when no event loop is running yet (figure not shown). + Schedule renders for every affected NDGraphic. + + Two paths share this entry point: + + - ``cancel_awaiting=True`` (rapid-fire inputs like slider drag, where + intermediate positions are disposable): schedule a fresh + ``_set_indices_`` task per call via :meth:`_render_request_latest`. + Older still-running tasks skip their write via the per-graphic + revision check. + - ``cancel_awaiting=False`` (play advance, step buttons, + LinearSelector, programmatic): every request must render. Requests + are queued per graphic and processed one at a time by + :meth:`_render_request`, so only one ``_set_indices_`` is in flight + per graphic from this path. """ - if cancel_awaiting: - for task in self._awaiting.values(): - task.cancel() - self._awaiting.clear() - for ndw in self._ndwidgets: for g in ndw.ndgraphics: if g.data is None or g.pause or g._block_indices: continue indices = {d: self._indices[d] for d in g.processor.slider_dims} - - try: - asyncio.get_running_loop() - except RuntimeError: - run_sync(g._set_indices_(indices)) - continue - - _loop.add_task(self._render_request, g, indices, cancel_awaiting, name="ndw-render") - - async def _render_request( - self, graphic: "NDGraphic", indices: dict[str, Any], cancel_awaiting: bool + task_name = f"ndw-render:{type(g).__name__}" + if g.name is not None: + task_name = f"{task_name}:{g.name}" + + if cancel_awaiting: + # bump revision so older still-running renders that complete + # later see rev < current and skip their write + self._render_rev[g] = self._render_rev.get(g, 0) + 1 + rev = self._render_rev[g] + _loop.add_task( + self._render_request_latest, g, indices, rev, name=task_name + ) + else: + rev = self._render_rev.get(g, 0) + self._render_request_queue.setdefault(g, deque()).append( + (indices, rev) + ) + # one queue processor per graphic; if one is already running, + # the appended entry will be picked up by it + if not self._render_request_active.get(g, False): + self._render_request_active[g] = True + _loop.add_task(self._render_request, g, name=task_name) + + async def _render_request_latest( + self, graphic: "NDGraphic", indices: dict[str, Any], rev: int ): - """Run the data pipeline for one graphic and write the result.""" - if cancel_awaiting: - self._awaiting[graphic] = asyncio.current_task() + """ + Schedule one ``_set_indices_`` task. Older still-running tasks skip + their write when ``rev < current``. Some ``data`` objects cancel the + previous in-flight read when a new index is requested; the resulting + :class:`CancelledError` is swallowed. + """ + if rev < self._render_rev.get(graphic, 0): + # a newer rapid-fire request superseded us; drop the write + return try: await graphic._set_indices_(indices) - except asyncio.CancelledError: + except CancelledError: + # ``data`` cancelled this read in favour of a newer one pass + + async def _render_request(self, graphic: "NDGraphic"): + """ + Process ``_render_request_queue[graphic]`` one entry at a time. Each + ``_set_indices_`` is awaited fully before the next entry is popped, + so only one ``_set_indices_`` is in flight per graphic from this + path. A concurrent :meth:`_render_request_latest` for the same + graphic can still cancel an in-flight read; the resulting + :class:`CancelledError` is swallowed. + """ + try: + queue = self._render_request_queue[graphic] + while queue: + indices, rev = queue.popleft() + if rev < self._render_rev.get(graphic, 0): + # a rapid-fire request superseded this queued entry; skip + continue + try: + await graphic._set_indices_(indices) + except CancelledError: + # concurrent _render_request_latest cancelled our read on ``data`` + pass + del self._render_request_queue[graphic] finally: - if cancel_awaiting and self._awaiting.get(graphic) is asyncio.current_task(): - del self._awaiting[graphic] + self._render_request_active[graphic] = False def __getitem__(self, dim): self._check_has_dim(dim) @@ -317,10 +381,13 @@ def _check_has_dim(self, dim): def pop_dim(self): pass - def push_dims(self, ref_ranges: dict[ + def push_dims( + self, + ref_ranges: dict[ str, tuple[Number, Number, Number] | tuple[Any] | RangeContinuous, - ],): + ], + ): for name, r in ref_ranges.items(): if isinstance(r, (RangeContinuous, RangeDiscrete)): diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index e01f97298..9fdefbef5 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -1,11 +1,11 @@ -import asyncio +from __future__ import annotations + from collections.abc import Sequence -from typing import Callable, Any, Literal +from typing import Callable, Any, Literal, TYPE_CHECKING import numpy as np from numpy.typing import ArrayLike -from ...layouts import Subplot from ...utils import ( subsample_array, ARRAY_LIKE_ATTRS, @@ -24,6 +24,9 @@ from ._index import ReferenceIndex from ._async import run_in_thread_pool, run_sync +if TYPE_CHECKING: + from ._ndw_subplot import NDWSubplot + class NDImageProcessor(NDProcessor): def __init__( @@ -253,7 +256,7 @@ async def get(self, indices: dict[str, Any]) -> ArrayProtocol: # final CUDA -> numpy conversion at the end of the pipeline if isinstance(window_output, CudaArrayProtocol): - window_output = await asyncio.to_thread(cuda_to_numpy, window_output) + window_output = await run_in_thread_pool(self._executor, cuda_to_numpy, window_output) return window_output @@ -291,7 +294,7 @@ class NDImage(NDGraphic): def __init__( self, ref_index: ReferenceIndex, - subplot: Subplot, + nd_subplot: NDWSubplot, data: ArrayProtocol | None, dims: Sequence[str], spatial_dims: ( @@ -327,8 +330,8 @@ def __init__( ref_index : ReferenceIndex The shared reference index that delivers slider updates to this graphic. - subplot : Subplot - parent subplot the NDGraphic is in + nd_subplot : NDWSubplot + parent NDWSubplot the NDGraphic is in data : array-like or None n-dimension image data array @@ -379,7 +382,7 @@ def __init__( f"spatial_dims: {spatial_dims}. Specified NDWidget ref_ranges: {ref_index.dims}" ) - super().__init__(subplot, name) + super().__init__(nd_subplot, name) self._ref_index = ref_index @@ -463,7 +466,7 @@ async def _create_graphic(self): attrs[k] = getattr(old_graphic, k) # delete the old graphic - self._subplot.delete_graphic(old_graphic) + self._nd_subplot.subplot.delete_graphic(old_graphic) # set any attributes that we're carrying over like cmap for attr, val in attrs.items(): @@ -471,7 +474,7 @@ async def _create_graphic(self): self._graphic = new_graphic - self._subplot.add_graphic(self._graphic) + self._nd_subplot.subplot.add_graphic(self._graphic) self._reset_camera() self._reset_histogram() @@ -483,7 +486,7 @@ def _reset_histogram(self): if not self.processor.compute_histogram: # hide right dock if histogram not desired - self._subplot.docks["right"].size = 0 + self._nd_subplot.subplot.docks["right"].size = 0 return if self.processor.histogram: @@ -491,8 +494,8 @@ def _reset_histogram(self): # histogram widget exists, update it self._histogram_widget.histogram = self.processor.histogram self._histogram_widget.images = self.graphic - if self._subplot.docks["right"].size < 1: - self._subplot.docks["right"].size = 80 + if self._nd_subplot.subplot.docks["right"].size < 1: + self._nd_subplot.subplot.docks["right"].size = 80 else: # make hist tool self._histogram_widget = HistogramLUTTool( @@ -500,8 +503,8 @@ def _reset_histogram(self): images=self.graphic, name=f"hist-{hex(id(self.graphic))}", ) - self._subplot.docks["right"].add_graphic(self._histogram_widget) - self._subplot.docks["right"].size = 80 + self._nd_subplot.subplot.docks["right"].add_graphic(self._histogram_widget) + self._nd_subplot.subplot.docks["right"].size = 80 self.graphic.reset_vmin_vmax() @@ -509,7 +512,7 @@ def _reset_camera(self): # set camera to a nice position based on whether it's a 2D ImageGraphic or 3D ImageVolumeGraphic if isinstance(self._graphic, (ImageGraphic, ImageYUVGraphic)): # set camera orthogonal to the xy plane, flip y axis - self._subplot.camera.set_state( + self._nd_subplot.subplot.camera.set_state( { "position": [0, 0, -1], "rotation": [0, 0, 0, 1], @@ -520,22 +523,22 @@ def _reset_camera(self): } ) - self._subplot.controller = "panzoom" - self._subplot.axes.intersection = None - self._subplot.auto_scale() + self._nd_subplot.controller = "panzoom" + self._nd_subplot.subplot.axes.intersection = None + self._nd_subplot.subplot.auto_scale() else: # It's not an ImageGraphic, set perspective projection - self._subplot.camera.fov = 50 - self._subplot.controller = "orbit" + self._nd_subplot.subplot.camera.fov = 50 + self._nd_subplot.controller = "orbit" # set all 3D dimension camera scales to positive since positive scales # are typically used for looking at volumes for dim in ["x", "y", "z"]: - if getattr(self._subplot.camera.local, f"scale_{dim}") < 0: - setattr(self._subplot.camera.local, f"scale_{dim}", 1) + if getattr(self._nd_subplot.subplot.camera.local, f"scale_{dim}") < 0: + setattr(self._nd_subplot.subplot.camera.local, f"scale_{dim}", 1) - self._subplot.auto_scale() + self._nd_subplot.subplot.auto_scale() @property def spatial_dims(self) -> tuple[str, str] | tuple[str, str, str]: diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index 920cd316c..e825c4269 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -1,14 +1,14 @@ -import asyncio +from __future__ import annotations + from collections.abc import Callable, Hashable, Sequence from functools import partial -from typing import Literal, Any, Type +from typing import Literal, Any, Type, TYPE_CHECKING from warnings import warn import numpy as np from numpy.lib.stride_tricks import sliding_window_view from numpy.typing import ArrayLike -from ....layouts import Subplot from ....graphics import ( ImageGraphic, LineGraphic, @@ -31,6 +31,9 @@ from .._index import ReferenceIndex from .._async import run_in_thread_pool, run_sync +if TYPE_CHECKING: + from .._ndw_subplot import NDWSubplot + # types for the other features FeatureCallable = Callable[[np.ndarray, slice], np.ndarray] ColorsType = np.ndarray | FeatureCallable | None @@ -544,13 +547,15 @@ async def get(self, indices: dict[str, Any]) -> dict[str, ArrayProtocol]: if isinstance(graphic_data, CudaArrayProtocol): data = self._finalize(graphic_data) else: - data = await run_in_thread_pool(self._executor, self._finalize, graphic_data) + data = await run_in_thread_pool( + self._executor, self._finalize, graphic_data + ) other = self._get_other_features(data, dw_slice) # final CUDA -> numpy conversion at the end of the pipeline if isinstance(data, CudaArrayProtocol): - data = await asyncio.to_thread(cuda_to_numpy, data) + data = await run_in_thread_pool(self._executor, cuda_to_numpy, data) return { "data": data, @@ -562,7 +567,7 @@ class NDPositions(NDGraphic): def __init__( self, ref_index: ReferenceIndex, - subplot: Subplot, + nd_subplot: NDWSubplot, data: Any, dims: Sequence[str], spatial_dims: tuple[str, str, str], @@ -611,7 +616,7 @@ def __init__( Parameters ---------- ref_index - subplot + nd_subplot data dims spatial_dims @@ -638,7 +643,7 @@ def __init__( processor_kwargs """ - super().__init__(subplot, name) + super().__init__(nd_subplot, name) self._ref_index = ref_index @@ -676,28 +681,46 @@ def __init__( self._graphic_type = graphic_type + # TODO: I think this is messy af, NDTimeseriesSubclass??? + # display_window = None overrides x_range_mode + if display_window is None: + x_range_mode = None + self._x_range_mode = None + self._last_x_range: tuple[float, float] | None = None self.x_range_mode = x_range_mode - self._last_x_range = np.array([0.0, 0.0], dtype=np.float32) + + # determine a min display_window for x_range_mode = "auto" + # determines required world space range for 3 datapoints + p_dim = self.processor.spatial_dims[1] + p_range = self._ref_index.ref_ranges[p_dim] + p_map = self.processor.slider_dim_transforms[p_dim] + p_span = p_range.stop - p_range.start + p_mid = p_range.start + p_span / 2 + i = p_map(p_mid) + i_increment = p_map(p_mid + p_range.step) + delta_p = p_range.step / max(1, i_increment - i) + self._min_display_window = 3 * delta_p self._timeseries = timeseries # TODO: I think this is messy af, NDTimeseriesSubclass??? if self._timeseries: # makes some assumptions about positional data that apply only to timeseries representations # probably don't want to maintain aspect - self._subplot.camera.maintain_aspect = False + self._nd_subplot.subplot.camera.maintain_aspect = False # auto x range modes make no sense for non-timeseries data self.x_range_mode = x_range_mode - if linear_selector: + # make a linear selector only if one does not already exist in this subplot + if linear_selector and "__ndw_manged_linear_selector" not in self._nd_subplot.subplot: self._linear_selector = LinearSelector( - 0, limits=(-np.inf, np.inf), edge_color="cyan" + 0, limits=(-np.inf, np.inf), edge_color="cyan", name="__ndw_manged_linear_selector" ) self._linear_selector.add_event_handler( self._linear_selector_handler, "selection" ) - self._subplot.add_graphic(self._linear_selector) + self._nd_subplot.subplot.add_graphic(self._linear_selector) else: self._linear_selector = None else: @@ -744,7 +767,7 @@ def graphic_type(self, graphic_type): if type(self.graphic) is graphic_type: return - self._subplot.delete_graphic(self._graphic) + self._nd_subplot.subplot.delete_graphic(self._graphic) self._graphic_type = graphic_type run_sync(self._create_graphic()) @@ -815,27 +838,36 @@ async def _set_indices_(self, indices: dict[Hashable, Any]): # TODO: I think this is messy af, NDTimeseriesSubclass??? # x range of the data - xr = data_slice[0, 0, 0], data_slice[0, -1, 0] - if self.x_range_mode is not None: - self.graphic._plot_area.x_range = xr + xr_data = data_slice[0, 0, 0], data_slice[0, -1, 0] - # if the update_from_view is polling, this prevents it from being called by setting the new last xrange - # in theory, but this doesn't seem to fully work yet, not a big deal right now can check later - self._last_x_range[:] = self.graphic._plot_area.x_range + if self.x_range_mode is not None: + # set x_range directly from the display_window, NOT from the xr_data + # this way it doesn't fight with the update_from_view_range() polling + dw = self.processor.display_window + hw = dw / 2 + center = indices[self.processor.spatial_dims[1]] + xr_view = center - hw, center + hw + self._nd_subplot.subplot.x_range = xr_view + # record post-write camera state so the polling animation does not + # mistake our own write for a user pan/zoom on the next tick + self._last_x_range = self._nd_subplot.subplot.x_range if self._linear_selector is not None: with pause_events( self._linear_selector ): # we don't want the linear selector change to update the indices - self._linear_selector.limits = xr + self._linear_selector.limits = xr_data # linear selector acts on `p` dim self._linear_selector.selection = indices[ self.processor.spatial_dims[1] ] def _linear_selector_handler(self, ev): - with block_indices_ctx(self): - self._ref_index.set_dim_index(self.processor.spatial_dims[1], ev.info["value"]) + with block_indices_ctx(*self._nd_subplot.nd_graphics): + # block index change in all NDGraphics that are not in the same subplot + self._ref_index.set_dim_index( + self.processor.spatial_dims[1], ev.info["value"] + ) def _tooltip_handler(self, graphic, pick_info): if isinstance(self.graphic, (LineCollection, ScatterCollection)): @@ -916,16 +948,26 @@ async def _create_graphic(self): for g in self._graphic.graphics: g.tooltip_format = partial(self._tooltip_handler, g) - self._subplot.add_graphic(self._graphic) + self._nd_subplot.subplot.add_graphic(self._graphic) # set the initial position and limits of the linear selector # x range of the data - xr = data_slice[0, 0, 0], data_slice[0, -1, 0] + xr_data = data_slice[0, 0, 0], data_slice[0, -1, 0] + + if self.x_range_mode is not None: + # set the intended view range before figure.show()'s autoscale runs + dw = self.processor.display_window + hw = dw / 2 + center = self.indices[self.processor.spatial_dims[1]] + xr_view = center - hw, center + hw + self.graphic._plot_area.x_range = xr_view + self._last_x_range = self.graphic._plot_area.x_range + if self._linear_selector is not None: with pause_events( self._linear_selector ): # we don't want the linear selector change to update the indices - self._linear_selector.limits = xr + self._linear_selector.limits = xr_data # linear selector acts on `p` dim self._linear_selector.selection = self.indices[ self.processor.spatial_dims[1] @@ -970,6 +1012,8 @@ def display_window(self) -> int | float | None: @display_window.setter def display_window(self, dw: int | float | None): self.processor.display_window = dw + if dw is None: + self.x_range_mode = None # force re-render run_sync(self._set_indices_(self.indices)) @@ -993,38 +1037,46 @@ def x_range_mode(self) -> Literal["fixed", "auto"] | None: @x_range_mode.setter def x_range_mode(self, mode: Literal[None, "fixed", "auto"]): + if mode not in (None, "fixed", "auto"): + raise ValueError( + f"x_range_mode must be None, 'fixed', or 'auto', got: {mode!r}" + ) + if mode == self._x_range_mode: + return + if self._x_range_mode == "auto": # old mode was auto - self._subplot.remove_animation(self._update_from_view_range) + self._nd_subplot.subplot.remove_animation(self._update_from_view_range) + self._last_x_range = None if mode == "auto": - self._subplot.add_animations(self._update_from_view_range) + # seed so the first tick does not fire spuriously + self._last_x_range = self._nd_subplot.subplot.x_range + self._nd_subplot.subplot.add_animations(self._update_from_view_range) self._x_range_mode = mode def _update_from_view_range(self): + # update from current x_range if it has changed if self._graphic is None: return - xr = self._subplot.x_range - - # the floating point error near zero gets nasty here - if np.allclose(xr, self._last_x_range, atol=1e-14): + xr = self._nd_subplot.subplot.x_range + if xr == self._last_x_range: + # x_range hasn't changed return - last_width = abs(self._last_x_range[1] - self._last_x_range[0]) - self._last_x_range[:] = xr + self._last_x_range = xr new_width = abs(xr[1] - xr[0]) - new_index = (xr[0] + xr[1]) / 2 + # make sure width is sufficient for >= 3 datapoints + if new_width < self._min_display_window: + new_width = self._min_display_window - if (new_index == self._ref_index[self.processor.spatial_dims[1]]) and ( - last_width == new_width - ): - return + new_index = (xr[0] + xr[1]) / 2 self.processor.display_window = new_width - self._ref_index.set_dim_index(self.processor.spatial_dims[1], new_index) + self._ref_index.set_dim_index(self.processor.spatial_dims[1], new_index, cancel_awaiting=True) @property def cmap(self) -> str | None: diff --git a/fastplotlib/widgets/nd_widget/_nd_vectors.py b/fastplotlib/widgets/nd_widget/_nd_vectors.py index a102eb552..51a7ea9e0 100644 --- a/fastplotlib/widgets/nd_widget/_nd_vectors.py +++ b/fastplotlib/widgets/nd_widget/_nd_vectors.py @@ -1,13 +1,11 @@ -import asyncio +from __future__ import annotations + from collections.abc import Sequence, Callable -from typing import Any +from typing import Any, TYPE_CHECKING -import numpy as np from numpy.typing import ArrayLike -from ...layouts import Subplot from ...utils import ( - subsample_array, ARRAY_LIKE_ATTRS, ArrayProtocol, CudaArrayProtocol, @@ -22,6 +20,9 @@ from ._index import ReferenceIndex from ._async import run_in_thread_pool, run_sync +if TYPE_CHECKING: + from ._ndw_subplot import NDWSubplot + class NDVectorsProcessor(NDProcessor): def __init__( @@ -139,7 +140,7 @@ def spatial_dims(self, sdims: tuple[str, str, str]): self.spatial_dims[-1] ] not in (2, 3): raise ValueError( - f"Spatial dimensions must haves shape (num_vecs, 2, [2 or 3]) you passed an array of shape {data.shape}" + f"Spatial dimensions must haves shape (num_vecs, 2, [2 or 3]) you passed {sdims}" ) async def get(self, indices: dict[str, Any]) -> ArrayProtocol: @@ -172,7 +173,7 @@ async def get(self, indices: dict[str, Any]) -> ArrayProtocol: # final CUDA -> numpy conversion at the end of the pipeline if isinstance(window_output, CudaArrayProtocol): - window_output = await asyncio.to_thread(cuda_to_numpy, window_output) + window_output = await run_in_thread_pool(self._executor, cuda_to_numpy, window_output) return window_output @@ -181,7 +182,7 @@ class NDVectors(NDGraphic): def __init__( self, ref_index: ReferenceIndex, - subplot: Subplot, + nd_subplot: NDWSubplot, data: ArrayProtocol | None, dims: Sequence[str], spatial_dims: tuple[ @@ -209,8 +210,8 @@ def __init__( ref_index : ReferenceIndex The shared reference index that delivers slider updates to this graphic. - subplot : Subplot - parent subplot the NDGraphic is in + nd_subplot : NDWSubplot + parent ndsubplot the NDGraphic is in data : array-like or None Shape [num_vectors, 2, 2] or [num_vectors, 3, 2]. data[:, :, 0] gives the positions, data[:, :, 1] gives directions @@ -254,7 +255,7 @@ def __init__( f"spatial_dims: {spatial_dims}. Specified NDWidget ref_ranges: {ref_index.dims}" ) - super().__init__(subplot, name) + super().__init__(nd_subplot, name) self._ref_index = ref_index @@ -306,7 +307,7 @@ async def _create_graphic(self): # check if we are replacing a graphic if old_graphic is not None: # delete the old graphic - self._subplot.delete_graphic(old_graphic) + self._nd_subplot.subplot.delete_graphic(old_graphic) # create the new graphic self._graphic = VectorsGraphic( @@ -315,7 +316,7 @@ async def _create_graphic(self): **self._graphic_kwargs ) - self._subplot.add_graphic(self._graphic) + self._nd_subplot.subplot.add_graphic(self._graphic) @property def spatial_dims(self) -> tuple[str, str, str]: diff --git a/fastplotlib/widgets/nd_widget/_ndw_subplot.py b/fastplotlib/widgets/nd_widget/_ndw_subplot.py index 3c655d662..469b110b6 100644 --- a/fastplotlib/widgets/nd_widget/_ndw_subplot.py +++ b/fastplotlib/widgets/nd_widget/_ndw_subplot.py @@ -35,6 +35,10 @@ def __init__(self, ndw, subplot: Subplot): self._nd_graphics = list() + @property + def subplot(self) -> Subplot: + return self._subplot + @property def nd_graphics(self) -> tuple[NDGraphic]: """all the NDGraphic instance in this subplot""" @@ -70,7 +74,7 @@ def add_nd_image( ): nd = NDImage( self.ndw.indices, - self._subplot, + nd_subplot=self, data=data, dims=dims, spatial_dims=spatial_dims, @@ -123,7 +127,7 @@ def add_nd_vectors( ) -> NDVectors: nd = NDVectors( self.ndw.indices, - self._subplot, + nd_subplot=self, data=data, dims=dims, spatial_dims=spatial_dims, @@ -142,7 +146,7 @@ def add_nd_scatter(self, *args, **kwargs): # TODO: better func signature here, send all kwargs to processor_kwargs nd = NDPositions( self.ndw.indices, - self._subplot, + self, *args, graphic_type=ScatterCollection, **kwargs, @@ -162,7 +166,7 @@ def add_nd_timeseries( ): nd = NDPositions( self.ndw.indices, - self._subplot, + self, *args, graphic_type=graphic_type, linear_selector=True, @@ -177,7 +181,7 @@ def add_nd_timeseries( def add_nd_lines(self, *args, **kwargs): nd = NDPositions( self.ndw.indices, - self._subplot, + self, *args, graphic_type=LineCollection, **kwargs, From b7d585d0dd9c10b1ad2ab7d5401f4eb1ef15a36a Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 11 May 2026 03:22:43 -0400 Subject: [PATCH 06/14] improvements --- fastplotlib/layouts/_plot_area.py | 18 +++++++-------- fastplotlib/widgets/nd_widget/_base.py | 10 ++++---- fastplotlib/widgets/nd_widget/_index.py | 13 +++++------ fastplotlib/widgets/nd_widget/_nd_image.py | 4 ++-- .../nd_widget/_nd_positions/_nd_positions.py | 23 ++++++++++++++----- fastplotlib/widgets/nd_widget/_nd_vectors.py | 4 ++-- 6 files changed, 40 insertions(+), 32 deletions(-) diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 030927540..cd7ab7bbf 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -898,16 +898,15 @@ def x_range(self) -> tuple[float, float]: Only valid for orthographic projections of the xy plane. Use camera.set_state() to set the camera position for arbitrary projections. """ - hw = self.camera.width / 2 + hw = self.camera.projection_matrix_inverse[0, 0] x = self.camera.local.x return x - hw, x + hw @x_range.setter def x_range(self, xr: tuple[float, float]): - width = xr[1] - xr[0] - x_mid = (xr[0] + xr[1]) / 2 - self.camera.width = width - self.camera.local.x = x_mid + hw = (xr[1] - xr[0]) / 2 + self.camera.zoom *= self.camera.projection_matrix_inverse[0, 0] / hw + self.camera.local.x = (xr[0] + xr[1]) / 2 @property def y_range(self) -> tuple[float, float]: @@ -916,16 +915,15 @@ def y_range(self) -> tuple[float, float]: Only valid for orthographic projections of the xy plane. Use camera.set_state() to set the camera position for arbitrary projections. """ - hh = self.camera.height / 2 + hh = self.camera.projection_matrix_inverse[1, 1] y = self.camera.local.y return y - hh, y + hh @y_range.setter def y_range(self, yr: tuple[float, float]): - height = yr[1] - yr[0] - y_mid = yr[0] + (height / 2) - self.camera.height = height - self.camera.local.y = y_mid + hh = (yr[1] - yr[0]) / 2 + self.camera.zoom *= self.camera.projection_matrix_inverse[1, 1] / hh + self.camera.local.y = (yr[0] + yr[1]) / 2 def remove_graphic(self, graphic: Graphic): """ diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index bd7769017..025c4cb5a 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -648,12 +648,12 @@ def graphic(self) -> Graphic: def indices(self) -> dict[str, Any]: raise NotImplementedError - async def _set_indices_(self, indices: dict[str, Any]): + async def _set_indices_(self): """ - Get the data slice for ``indices`` from the processor and write it to the graphic. + Get the data slice for the current index from the processor and write it to the graphic. - Semi-private: only ``ReferenceIndex`` should call this. Construction-time call - sites use :func:`run_sync` to drive it synchronously. + Semi-private: only ``ReferenceIndex`` should call this. _create_graphic uses `run_sync` + to run it sync """ pass @@ -678,7 +678,7 @@ def data(self, data: Any): run_sync(self._create_graphic()) # force a render - run_sync(self._set_indices_(self.indices)) + run_sync(self._set_indices_()) @property def shape(self) -> dict[str, int]: diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index 0f96fbe18..ec47013cc 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -301,7 +301,6 @@ def _render_indices(self, cancel_awaiting: bool = False): for g in ndw.ndgraphics: if g.data is None or g.pause or g._block_indices: continue - indices = {d: self._indices[d] for d in g.processor.slider_dims} task_name = f"ndw-render:{type(g).__name__}" if g.name is not None: task_name = f"{task_name}:{g.name}" @@ -312,12 +311,12 @@ def _render_indices(self, cancel_awaiting: bool = False): self._render_rev[g] = self._render_rev.get(g, 0) + 1 rev = self._render_rev[g] _loop.add_task( - self._render_request_latest, g, indices, rev, name=task_name + self._render_request_latest, g, rev, name=task_name ) else: rev = self._render_rev.get(g, 0) self._render_request_queue.setdefault(g, deque()).append( - (indices, rev) + rev ) # one queue processor per graphic; if one is already running, # the appended entry will be picked up by it @@ -326,7 +325,7 @@ def _render_indices(self, cancel_awaiting: bool = False): _loop.add_task(self._render_request, g, name=task_name) async def _render_request_latest( - self, graphic: "NDGraphic", indices: dict[str, Any], rev: int + self, graphic: "NDGraphic", rev: int ): """ Schedule one ``_set_indices_`` task. Older still-running tasks skip @@ -338,7 +337,7 @@ async def _render_request_latest( # a newer rapid-fire request superseded us; drop the write return try: - await graphic._set_indices_(indices) + await graphic._set_indices_() except CancelledError: # ``data`` cancelled this read in favour of a newer one pass @@ -355,12 +354,12 @@ async def _render_request(self, graphic: "NDGraphic"): try: queue = self._render_request_queue[graphic] while queue: - indices, rev = queue.popleft() + rev = queue.popleft() if rev < self._render_rev.get(graphic, 0): # a rapid-fire request superseded this queued entry; skip continue try: - await graphic._set_indices_(indices) + await graphic._set_indices_() except CancelledError: # concurrent _render_request_latest cancelled our read on ``data`` pass diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index 9fdefbef5..248650064 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -561,8 +561,8 @@ def indices(self) -> dict[str, Any]: """get or set the indices, managed by the ReferenceIndex, users usually don't want to set this manually""" return {d: self._ref_index[d] for d in self.processor.slider_dims} - async def _set_indices_(self, indices: dict[str, Any]): - self.graphic.data = await self.processor.get(indices) + async def _set_indices_(self): + self.graphic.data = await self.processor.get(self.indices) @property def compute_histogram(self) -> bool: diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index e825c4269..652249d69 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -785,10 +785,13 @@ def spatial_dims(self, dims: tuple[str, str, str]): def indices(self) -> dict[Hashable, Any]: return {d: self._ref_index[d] for d in self.processor.slider_dims} - async def _set_indices_(self, indices: dict[Hashable, Any]): + async def _set_indices_(self): if self.data is None: return + # fetch the latest indices from the ReferenceIndex + indices = self.indices + new_features = await self.processor.get(indices) data_slice = new_features["data"] @@ -1076,7 +1079,15 @@ def _update_from_view_range(self): new_index = (xr[0] + xr[1]) / 2 self.processor.display_window = new_width - self._ref_index.set_dim_index(self.processor.spatial_dims[1], new_index, cancel_awaiting=True) + + # block scheduling an additional async _set_indices_ for ndgraphics in this subplot + with block_indices_ctx(*self._nd_subplot.nd_graphics): + p_dim = self.processor.spatial_dims[1] + self._ref_index.set_dim_index(p_dim, new_index) + + # run this ndgraphic update immediately so graphic data and linear selector are in sync with the + # camera, otherwise you get laggy movement + run_sync(self._set_indices_()) @property def cmap(self) -> str | None: @@ -1097,7 +1108,7 @@ def cmap(self, new: str | None): self._graphic.cmap = new self._cmap = new # force a re-render - run_sync(self._set_indices_(self.indices)) + run_sync(self._set_indices_()) @property def cmap_each(self) -> np.ndarray[str] | None: @@ -1163,7 +1174,7 @@ def markers(self, new: str | None): self.graphic.markers = new self._markers = new # force a re-render - run_sync(self._set_indices_(self.indices)) + run_sync(self._set_indices_()) @property def sizes(self) -> float | Sequence[float] | None: @@ -1182,7 +1193,7 @@ def sizes(self, new: float | Sequence[float] | None): self.graphic.sizes = new self._sizes = new # force a re-render - run_sync(self._set_indices_(self.indices)) + run_sync(self._set_indices_()) @property def thickness(self) -> float | Sequence[float] | None: @@ -1201,4 +1212,4 @@ def thickness(self, new: float | Sequence[float] | None): self.graphic.thickness = new self._thickness = new # force a re-render - run_sync(self._set_indices_(self.indices)) + run_sync(self._set_indices_()) diff --git a/fastplotlib/widgets/nd_widget/_nd_vectors.py b/fastplotlib/widgets/nd_widget/_nd_vectors.py index 51a7ea9e0..7d15103aa 100644 --- a/fastplotlib/widgets/nd_widget/_nd_vectors.py +++ b/fastplotlib/widgets/nd_widget/_nd_vectors.py @@ -338,8 +338,8 @@ def indices(self) -> dict[str, Any]: """get or set the indices, managed by the ReferenceIndex, users usually don't want to set this manually""" return {d: self._ref_index[d] for d in self.processor.slider_dims} - async def _set_indices_(self, indices: dict[str, Any]): - data_slice = await self.processor.get(indices) + async def _set_indices_(self): + data_slice = await self.processor.get(self.indices) self.graphic.positions = data_slice[:, 0] self.graphic.directions = data_slice[:, 1] From ebc500f30d36947b891a2569ed8ff82ca5731eb8 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 11 May 2026 03:46:42 -0400 Subject: [PATCH 07/14] fixes --- fastplotlib/widgets/nd_widget/_base.py | 8 ++++---- .../widgets/nd_widget/_nd_positions/_nd_positions.py | 4 ++-- fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index 025c4cb5a..376118f21 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -717,7 +717,7 @@ def slider_dim_transforms( """get or set the slider_dim_transforms, see docstring for details""" self.processor.slider_dim_transforms = maps # force a render - run_sync(self._set_indices_(self.indices)) + run_sync(self._set_indices_()) @property def window_funcs( @@ -736,7 +736,7 @@ def window_funcs( ): self.processor.window_funcs = window_funcs # force a render - run_sync(self._set_indices_(self.indices)) + run_sync(self._set_indices_()) @property def window_order(self) -> tuple[str, ...]: @@ -747,7 +747,7 @@ def window_order(self) -> tuple[str, ...]: def window_order(self, order: tuple[str] | None): self.processor.window_order = order # force a render - run_sync(self._set_indices_(self.indices)) + run_sync(self._set_indices_()) @property def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: @@ -761,7 +761,7 @@ def spatial_func( """get or set the spatial_func, see docstring for details""" self.processor.spatial_func = func # force a render - run_sync(self._set_indices_(self.indices)) + run_sync(self._set_indices_()) # def _repr_text_(self) -> str: # return ndg_fmt_text(self) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index 652249d69..65b2e1bc4 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -779,7 +779,7 @@ def spatial_dims(self) -> tuple[str, str, str]: def spatial_dims(self, dims: tuple[str, str, str]): self.processor.spatial_dims = dims # force re-render - run_sync(self._set_indices_(self.indices)) + run_sync(self._set_indices_()) @property def indices(self) -> dict[Hashable, Any]: @@ -1019,7 +1019,7 @@ def display_window(self, dw: int | float | None): self.x_range_mode = None # force re-render - run_sync(self._set_indices_(self.indices)) + run_sync(self._set_indices_()) @property def datapoints_window_func(self) -> tuple[Callable, str, int | float] | None: diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py index 9278312fc..f82575016 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py @@ -72,7 +72,7 @@ def tooltip_format(self, n: int, p: int): p += self._dw_slice.start return str(self.data[self._tooltip_columns[n]][p]) - def get(self, indices: dict[str, Any]) -> dict[str, np.ndarray]: + async def get(self, indices: dict[str, Any]) -> dict[str, np.ndarray]: # TODO: LOD by using a step size according to max_p # TODO: Also what to do if display_window is None and data # hasn't changed when indices keeps getting set, cache? From ff60d0f9b18ac005c50f1df8e569d2f1a56b5a2f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 11 May 2026 04:11:46 -0400 Subject: [PATCH 08/14] x_range fix --- fastplotlib/layouts/_plot_area.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index cd7ab7bbf..0c07fbb4b 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -895,7 +895,7 @@ def _auto_scale_scene( def x_range(self) -> tuple[float, float]: """ Get or set the x-range currently in view. - Only valid for orthographic projections of the xy plane. + Really only valid for orthographic projections of the xy plane. Use camera.set_state() to set the camera position for arbitrary projections. """ hw = self.camera.projection_matrix_inverse[0, 0] @@ -905,14 +905,19 @@ def x_range(self) -> tuple[float, float]: @x_range.setter def x_range(self, xr: tuple[float, float]): hw = (xr[1] - xr[0]) / 2 - self.camera.zoom *= self.camera.projection_matrix_inverse[0, 0] / hw + if self.camera.fov > 0: + # really shouldn't use this for fov > 0 but ¯\_(ツ)_/¯ + self.camera.zoom *= self.camera.projection_matrix_inverse[0, 0] / hw + else: + # sets correct x_range for orthographic projection of xy plane + self.camera.width = (xr[1] - xr[0]) * self.camera.zoom self.camera.local.x = (xr[0] + xr[1]) / 2 @property def y_range(self) -> tuple[float, float]: """ Get or set the y-range currently in view. - Only valid for orthographic projections of the xy plane. + Really only valid for orthographic projections of the xy plane. Use camera.set_state() to set the camera position for arbitrary projections. """ hh = self.camera.projection_matrix_inverse[1, 1] @@ -922,7 +927,11 @@ def y_range(self) -> tuple[float, float]: @y_range.setter def y_range(self, yr: tuple[float, float]): hh = (yr[1] - yr[0]) / 2 - self.camera.zoom *= self.camera.projection_matrix_inverse[1, 1] / hh + if self.camera.fov > 0: + # shouldn't really do this but ¯\_(ツ)_/¯ + self.camera.zoom *= self.camera.projection_matrix_inverse[1, 1] / hh + else: + self.camera.height = (yr[1] - yr[0]) * self.camera.zoom self.camera.local.y = (yr[0] + yr[1]) / 2 def remove_graphic(self, graphic: Graphic): From 2465a3a323c7ce1a94e39f264b032a965dbba23e Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 11 May 2026 04:13:44 -0400 Subject: [PATCH 09/14] fix NDPandasProcessor --- .../nd_widget/_nd_positions/_pandas.py | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py index f82575016..fc15277a0 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py @@ -44,7 +44,7 @@ def data(self, data: pd.DataFrame): if not isinstance(data, pd.DataFrame): raise TypeError - self._data= data + self._data = data @property def columns(self) -> list[tuple[str, str] | tuple[str, str, str]]: @@ -79,14 +79,23 @@ async def get(self, indices: dict[str, Any]) -> dict[str, np.ndarray]: # assume no additional slider dims self._dw_slice = self._get_dw_slice(indices) - gdata_shape = len(self.columns), self._dw_slice.stop - self._dw_slice.start, 3 + + column_stacks = [ + np.column_stack( + [self.data[c][self._dw_slice] for c in col] + ) for col in self.columns + ] + if len(column_stacks) > 0: + n_samples = column_stacks[0].shape[0] + else: + n_samples = 0 + + gdata_shape = len(self.columns), n_samples, 3 graphic_data = np.zeros(shape=gdata_shape, dtype=np.float32) - for i, col in enumerate(self.columns): - graphic_data[i, :, :len(col)] = np.column_stack( - [self.data[c][self._dw_slice] for c in col] - ) + for i, (col, column_stack) in enumerate(zip(self.columns, column_stacks)): + graphic_data[i, :, :len(col)] = column_stack data = self._finalize(graphic_data) other = self._get_other_features(data, self._dw_slice) From d364425552d47fed7d0f466252368c371dc69f7a Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 11 May 2026 05:56:53 -0400 Subject: [PATCH 10/14] fix --- fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index 65b2e1bc4..94b2cc391 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -811,7 +811,7 @@ async def _set_indices_(self): g.data[:, : new_data.shape[1]] = new_data for feature in ["colors", "sizes", "markers"]: - value = new_features[feature] + value = new_features.get(feature, None) match value: case None: @@ -922,7 +922,7 @@ async def _create_graphic(self): if isinstance(self._graphic, (LineCollection, ScatterCollection)): for l, g in enumerate(self.graphic.graphics): for feature in ["colors", "sizes", "markers"]: - value = new_features[feature] + value = new_features.get(feature, None) match value: case None: From ab5cbeaed798058abf1d0ea4e442357014b24d3e Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 11 May 2026 06:20:19 -0400 Subject: [PATCH 11/14] HighlightSelector fix to append None --- fastplotlib/graphics/selectors/_highlight_selector.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fastplotlib/graphics/selectors/_highlight_selector.py b/fastplotlib/graphics/selectors/_highlight_selector.py index b509022e9..3ba08a676 100644 --- a/fastplotlib/graphics/selectors/_highlight_selector.py +++ b/fastplotlib/graphics/selectors/_highlight_selector.py @@ -715,12 +715,12 @@ def append(self, dict_or_index: dict | int) -> None: if self._selection_options is not None: # options mode index = dict_or_index - if not isinstance(index, Integral): + 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: + 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}) From 9cdebeaf055d5ea7767a31d3c152da9a885dd64e Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 11 May 2026 07:21:41 -0400 Subject: [PATCH 12/14] cleanup better --- fastplotlib/widgets/nd_widget/_base.py | 7 +- fastplotlib/widgets/nd_widget/_index.py | 148 ++++++++++-------- fastplotlib/widgets/nd_widget/_nd_image.py | 8 +- .../nd_widget/_nd_positions/_nd_positions.py | 8 +- fastplotlib/widgets/nd_widget/_nd_vectors.py | 8 +- 5 files changed, 106 insertions(+), 73 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index 376118f21..3d4b2a74a 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -648,9 +648,12 @@ def graphic(self) -> Graphic: def indices(self) -> dict[str, Any]: raise NotImplementedError - async def _set_indices_(self): + async def _set_indices_(self, indices: dict[str, Any] = None): """ - Get the data slice for the current index from the processor and write it to the graphic. + Get the data slice for the index from the processor and write it to the graphic. + + If indices is None, it uses the latest indices from the ReferenceIndex. Otherwise it uses the + indices passed when the update was scheduled. Semi-private: only ``ReferenceIndex`` should call this. _create_graphic uses `run_sync` to run it sync diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index ec47013cc..5ff331655 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -4,7 +4,7 @@ from concurrent.futures import CancelledError from dataclasses import dataclass from numbers import Number -from typing import Sequence, Any, Callable +from typing import Sequence, Any, Callable, Iterator from typing import TYPE_CHECKING @@ -206,18 +206,18 @@ def __init__( self._ndwidgets: list[NDWidget] = list() - # per-graphic render revision. Bumped on every ``cancel_awaiting=True`` + # per-NDGraphic fetch update revision. Bumped on every ``cancel_awaiting=True`` # call (always-latest inputs, such as slider drag). A scheduled render # carries the revision it was created under and skips its final write # if a newer revision has been requested in the meantime. - self._render_rev: dict[NDGraphic, int] = dict() + self._fetch_rev: dict[NDGraphic, int] = dict() - # per-graphic queue of pending render requests for the serial drain + # per-graphic queue of pending fetch requests for the serial drain # path (``cancel_awaiting=False``: play, step, programmatic, # LinearSelector). Each entry is ``(indices, rev)``. Drained by # :meth:`_render_request` so the underlying data source only ever sees # one read at a time. - self._render_request_queue: dict[ + self._fetch_request_queue: dict[ NDGraphic, deque[tuple[dict[str, Any], int]] ] = dict() @@ -225,7 +225,7 @@ def __init__( # ``_render_request_queue[g]``? Ensures only one drain coroutine is # alive per graphic - subsequent ``cancel_awaiting=False`` calls just # append to the queue. - self._render_request_active: dict[NDGraphic, bool] = dict() + self._fetch_request_active: dict[NDGraphic, bool] = dict() @property def ref_ranges(self) -> dict[str, RangeContinuous | RangeDiscrete]: @@ -247,12 +247,18 @@ def set(self, indices: dict[str, Any], cancel_awaiting: bool = False): for dim, value in indices.items(): self._indices[dim] = self._clamp(dim, value) - self._render_indices(cancel_awaiting=cancel_awaiting) + self._fetch_indices(cancel_awaiting=cancel_awaiting) self._indices_changed() + @property + def ndgraphics(self) -> Iterator[NDGraphic]: + """All the NDGraphics this ReferenceIndex instance manges""" + for ndw in self._ndwidgets: + yield from ndw.ndgraphics + def set_dim_index(self, dim: str, index, cancel_awaiting: bool = False): """ - Set the index for a single dimension and trigger a render. + Set the index for a single dimension and trigger a IO fetch -> process pipeline -> update graphic Parameters ---------- @@ -268,7 +274,12 @@ def set_dim_index(self, dim: str, index, cancel_awaiting: bool = False): """ self._check_has_dim(dim) self._indices[dim] = self._clamp(dim, index) - self._render_indices(cancel_awaiting=cancel_awaiting) + + # set only for NDGraphics that have this dim + for ndg in self.ndgraphics: + if dim in ndg.dims: + self._schedule_fetch(ndg, cancel_awaiting=cancel_awaiting) + self._indices_changed() def _clamp(self, dim, value): @@ -280,9 +291,16 @@ def _clamp(self, dim, value): return value - def _render_indices(self, cancel_awaiting: bool = False): + def _fetch_indices(self, cancel_awaiting: bool = False): + """ + Schedule IO fetch and data processing for every affected NDGraphic. """ - Schedule renders for every affected NDGraphic. + for g in self.ndgraphics: + self._schedule_fetch(g, cancel_awaiting=cancel_awaiting) + + def _schedule_fetch(self, ndg: NDGraphic, cancel_awaiting: bool = False): + """ + schedule fetch for an NDGraphic Two paths share this entry point: @@ -296,35 +314,62 @@ def _render_indices(self, cancel_awaiting: bool = False): are queued per graphic and processed one at a time by :meth:`_render_request`, so only one ``_set_indices_`` is in flight per graphic from this path. + """ - for ndw in self._ndwidgets: - for g in ndw.ndgraphics: - if g.data is None or g.pause or g._block_indices: + if ndg.data is None or ndg.pause or ndg._block_indices: + return + + task_name = f"ndw-fetch:{type(ndg).__name__}" + if ndg.name is not None: + task_name = f"{task_name}:{ndg.name}" + + if cancel_awaiting: + # bump revision so older still-running renders that complete + # later see rev < current and skip their write + self._fetch_rev[ndg] = self._fetch_rev.get(ndg, 0) + 1 + rev = self._fetch_rev[ndg] + _loop.add_task( + self._fetch_request_latest, ndg, rev, name=task_name + ) + else: + rev = self._fetch_rev.get(ndg, 0) + # provide index at schedule time so all data is played back sequentially + indices = {d: self._indices[d] for d in ndg.processor.slider_dims} + self._fetch_request_queue.setdefault(ndg, deque()).append( + (indices, rev) + ) + # one queue processor per graphic; if one is already running, + # the appended entry will be picked up by it + if not self._fetch_request_active.get(ndg, False): + self._fetch_request_active[ndg] = True + _loop.add_task(self._fetch_request, ndg, name=task_name) + + async def _fetch_request(self, graphic: "NDGraphic"): + """ + Process ``_fetch_request_queue[graphic]`` one entry at a time. Each + ``_set_indices_`` is awaited fully before the next entry is popped, + so only one ``_set_indices_`` is in flight per graphic from this + path. A concurrent :meth:`_fetch_request_latest` for the same + graphic can still cancel an in-flight read; the resulting + :class:`CancelledError` is dropped. + """ + try: + queue = self._fetch_request_queue[graphic] + while queue: + indices, rev = queue.popleft() + if rev < self._fetch_rev.get(graphic, 0): + # a rapid-fire request superseded this queued entry; skip continue - task_name = f"ndw-render:{type(g).__name__}" - if g.name is not None: - task_name = f"{task_name}:{g.name}" - - if cancel_awaiting: - # bump revision so older still-running renders that complete - # later see rev < current and skip their write - self._render_rev[g] = self._render_rev.get(g, 0) + 1 - rev = self._render_rev[g] - _loop.add_task( - self._render_request_latest, g, rev, name=task_name - ) - else: - rev = self._render_rev.get(g, 0) - self._render_request_queue.setdefault(g, deque()).append( - rev - ) - # one queue processor per graphic; if one is already running, - # the appended entry will be picked up by it - if not self._render_request_active.get(g, False): - self._render_request_active[g] = True - _loop.add_task(self._render_request, g, name=task_name) - - async def _render_request_latest( + try: + await graphic._set_indices_(indices) + except CancelledError: + # concurrent _fetch_request_latest canceled our read on ``data`` + pass + del self._fetch_request_queue[graphic] + finally: + self._fetch_request_active[graphic] = False + + async def _fetch_request_latest( self, graphic: "NDGraphic", rev: int ): """ @@ -333,7 +378,7 @@ async def _render_request_latest( previous in-flight read when a new index is requested; the resulting :class:`CancelledError` is swallowed. """ - if rev < self._render_rev.get(graphic, 0): + if rev < self._fetch_rev.get(graphic, 0): # a newer rapid-fire request superseded us; drop the write return try: @@ -342,31 +387,6 @@ async def _render_request_latest( # ``data`` cancelled this read in favour of a newer one pass - async def _render_request(self, graphic: "NDGraphic"): - """ - Process ``_render_request_queue[graphic]`` one entry at a time. Each - ``_set_indices_`` is awaited fully before the next entry is popped, - so only one ``_set_indices_`` is in flight per graphic from this - path. A concurrent :meth:`_render_request_latest` for the same - graphic can still cancel an in-flight read; the resulting - :class:`CancelledError` is swallowed. - """ - try: - queue = self._render_request_queue[graphic] - while queue: - rev = queue.popleft() - if rev < self._render_rev.get(graphic, 0): - # a rapid-fire request superseded this queued entry; skip - continue - try: - await graphic._set_indices_() - except CancelledError: - # concurrent _render_request_latest cancelled our read on ``data`` - pass - del self._render_request_queue[graphic] - finally: - self._render_request_active[graphic] = False - def __getitem__(self, dim): self._check_has_dim(dim) return self._indices[dim] diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index 248650064..f66fc6d43 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -561,8 +561,12 @@ def indices(self) -> dict[str, Any]: """get or set the indices, managed by the ReferenceIndex, users usually don't want to set this manually""" return {d: self._ref_index[d] for d in self.processor.slider_dims} - async def _set_indices_(self): - self.graphic.data = await self.processor.get(self.indices) + async def _set_indices_(self, indices: dict[str, Any] = None): + if indices is None: + # current indices, else use the indices passed at schedule time + indices = self.indices + + self.graphic.data = await self.processor.get(indices) @property def compute_histogram(self) -> bool: diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index 94b2cc391..acd24ae59 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -785,12 +785,14 @@ def spatial_dims(self, dims: tuple[str, str, str]): def indices(self) -> dict[Hashable, Any]: return {d: self._ref_index[d] for d in self.processor.slider_dims} - async def _set_indices_(self): + async def _set_indices_(self, indices: dict[str, Any] = None): if self.data is None: return - # fetch the latest indices from the ReferenceIndex - indices = self.indices + if indices is None: + # fetch the latest indices from the ReferenceIndex + # else used passed indices from schedule time + indices = self.indices new_features = await self.processor.get(indices) data_slice = new_features["data"] diff --git a/fastplotlib/widgets/nd_widget/_nd_vectors.py b/fastplotlib/widgets/nd_widget/_nd_vectors.py index 7d15103aa..654a7d60a 100644 --- a/fastplotlib/widgets/nd_widget/_nd_vectors.py +++ b/fastplotlib/widgets/nd_widget/_nd_vectors.py @@ -338,8 +338,12 @@ def indices(self) -> dict[str, Any]: """get or set the indices, managed by the ReferenceIndex, users usually don't want to set this manually""" return {d: self._ref_index[d] for d in self.processor.slider_dims} - async def _set_indices_(self): - data_slice = await self.processor.get(self.indices) + async def _set_indices_(self, indices: dict[str, Any] = None): + if indices is None: + # use latest indices if None, else use passed indices from schedule time + indices = self.indices + + data_slice = await self.processor.get(indices) self.graphic.positions = data_slice[:, 0] self.graphic.directions = data_slice[:, 1] From c522a70a28b17a9836c306360d39ccdb67195240 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 11 May 2026 07:29:48 -0400 Subject: [PATCH 13/14] know ndg current dipslayed indices --- fastplotlib/widgets/nd_widget/_base.py | 8 ++++++++ fastplotlib/widgets/nd_widget/_nd_image.py | 1 + .../widgets/nd_widget/_nd_positions/_nd_positions.py | 2 ++ fastplotlib/widgets/nd_widget/_nd_vectors.py | 2 ++ 4 files changed, 13 insertions(+) diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index 3d4b2a74a..5cba6dba7 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -619,6 +619,9 @@ def __init__( # user settable bool to make the graphic unresponsive to change in the ReferenceIndex self._pause = False + # the indices that current graphic data reflects + self._last_indices = None + async def _create_graphic(self): raise NotImplementedError @@ -644,6 +647,11 @@ def processor(self) -> NDProcessor: def graphic(self) -> Graphic: raise NotImplementedError + @property + def indices_displayed(self) -> dict[str, Any]: + """the indices that the graphic current reflects, not accounting for render time""" + return self._last_indices + @property def indices(self) -> dict[str, Any]: raise NotImplementedError diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index f66fc6d43..951fc5a55 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -567,6 +567,7 @@ async def _set_indices_(self, indices: dict[str, Any] = None): indices = self.indices self.graphic.data = await self.processor.get(indices) + self._last_indices = indices @property def compute_histogram(self) -> bool: diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index acd24ae59..52cc69cff 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -867,6 +867,8 @@ async def _set_indices_(self, indices: dict[str, Any] = None): self.processor.spatial_dims[1] ] + self._last_indices = indices + def _linear_selector_handler(self, ev): with block_indices_ctx(*self._nd_subplot.nd_graphics): # block index change in all NDGraphics that are not in the same subplot diff --git a/fastplotlib/widgets/nd_widget/_nd_vectors.py b/fastplotlib/widgets/nd_widget/_nd_vectors.py index 654a7d60a..138ddee95 100644 --- a/fastplotlib/widgets/nd_widget/_nd_vectors.py +++ b/fastplotlib/widgets/nd_widget/_nd_vectors.py @@ -347,6 +347,8 @@ async def _set_indices_(self, indices: dict[str, Any] = None): self.graphic.positions = data_slice[:, 0] self.graphic.directions = data_slice[:, 1] + self._last_indices = indices + @property def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: """get or set the spatial_func, see docstring for details""" From 825649116d1adfa41763dbaf0d55ecfc761f836f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 11 May 2026 22:50:59 -0400 Subject: [PATCH 14/14] improve example --- examples/ndwidget/timeseries.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/ndwidget/timeseries.py b/examples/ndwidget/timeseries.py index 9d7ba851f..b2fd6ff6e 100644 --- a/examples/ndwidget/timeseries.py +++ b/examples/ndwidget/timeseries.py @@ -50,6 +50,7 @@ }, cmap="jet", x_range_mode="auto", + display_window=np.pi * 10, name="nd-sine" )