Skip to content

Commit

Permalink
feat(interactive.imagetool): add ImageTool window manager
Browse files Browse the repository at this point in the history
Start the manager with the cli command `itool-manager`. While running, all calls to `erlab.interactive.imagetool.itool` will make the ImageTool open in a separate process. The behavior can be controlled with a new keyword argument, `use_manager`.
  • Loading branch information
kmnhan committed Jun 17, 2024
1 parent 5d573be commit b52d249
Show file tree
Hide file tree
Showing 7 changed files with 530 additions and 32 deletions.
18 changes: 17 additions & 1 deletion docs/source/user-guide/imagetool.rst
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ create a new ImageTool instance and handle the event loop execution: ::
import erlab.interactive as eri
eri.itool(data)

Another way is to use the ``qshow`` accessor: ::
Another way is to use the :class:`qshow
<erlab.accessors.utils.InteractiveDataArrayAccessor>` accessor: ::

data.qshow()

Expand Down Expand Up @@ -81,3 +82,18 @@ related to 'shifting' a cursor usually involves holding :kbd:`Shift`.
- Move all cursors around
* - :kbd:`Alt` while dragging a cursor line
- Make all cursor lines move together


Using the ImageTool manager
---------------------------
One drawback of using interactive tools with Jupyter notebooks is that the tool will be
a blocking call. This means that you cannot run any other code while the tool is
running. To get around this, you can use the :class:`ImageToolManager
<erlab.interactive.imagetool.ImageToolManager>`.

In the environment where ERLabPy installed, run ``itool-manager`` to start the manager.
Any subsequent invocation with :class:`qshow <erlab.accessors.utils.InteractiveDataArrayAccessor>` will be handled by the manager.

Note that the manager is designed to be global, so you can only have one manager running
on a single machine that will handle all ImageTool instances opened from different
notebooks.
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ docs = [
"sphinx-design",
]

[project.gui-scripts]
itool-manager = "erlab.interactive.imagetool.manager:main"

[project.urls]
Documentation = "https://erlabpy.readthedocs.io"
Expand Down
12 changes: 7 additions & 5 deletions src/erlab/accessors/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,19 +113,21 @@ def __call__(self, *args, **kwargs):
return self.itool(*args, **kwargs)
else:
if importlib.util.find_spec("hvplot"):
return self._obj.hvplot(*args, **kwargs)

raise ValueError("Data must have at least two dimensions.")
self.hvplot(*args, **kwargs)

Check warning on line 116 in src/erlab/accessors/utils.py

View check run for this annotation

Codecov / codecov/patch

src/erlab/accessors/utils.py#L116

Added line #L116 was not covered by tests
else:
raise ValueError("Data must have at least two dimensions.")

Check warning on line 118 in src/erlab/accessors/utils.py

View check run for this annotation

Codecov / codecov/patch

src/erlab/accessors/utils.py#L118

Added line #L118 was not covered by tests

def itool(self, *args, **kwargs):
"""Shortcut for :func:`itool <erlab.interactive.imagetool.itool>`.
Parameters
----------
*args
Positional arguments passed onto :func:`itool <erlab.interactive.imagetool.itool>`.
Positional arguments passed onto :func:`itool
<erlab.interactive.imagetool.itool>`.
**kwargs
Keyword arguments passed onto :func:`itool <erlab.interactive.imagetool.itool>`.
Keyword arguments passed onto :func:`itool
<erlab.interactive.imagetool.itool>`.
"""
from erlab.interactive.imagetool import itool
Expand Down
58 changes: 43 additions & 15 deletions src/erlab/interactive/imagetool/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,35 @@
from erlab.interactive.imagetool.slicer import ArraySlicer


def _parse_input(
data: Collection[xr.DataArray | npt.NDArray]
| xr.DataArray
| npt.NDArray
| xr.Dataset,
) -> list[xr.DataArray]:
if isinstance(data, xr.Dataset):
data = [d for d in data.data_vars.values() if d.ndim >= 2 and d.ndim <= 4]
if len(data) == 0:
raise ValueError("No valid data for ImageTool found in the Dataset")

Check warning on line 55 in src/erlab/interactive/imagetool/__init__.py

View check run for this annotation

Codecov / codecov/patch

src/erlab/interactive/imagetool/__init__.py#L53-L55

Added lines #L53 - L55 were not covered by tests

if isinstance(data, np.ndarray | xr.DataArray):
data = (data,)

return [xr.DataArray(d) if not isinstance(d, xr.DataArray) else d for d in data]


def itool(
data: Collection[xr.DataArray | npt.NDArray]
| xr.DataArray
| npt.NDArray
| xr.Dataset,
link: bool = False,
link_colors: bool = True,
use_manager: bool = True,
execute: bool | None = None,
**kwargs,
) -> ImageTool | list[ImageTool] | None:
"""Create and display an ImageTool window.
"""Create and display ImageTool windows.
Parameters
----------
Expand All @@ -66,9 +84,14 @@ def itool(
link_colors
Whether to link the color maps between multiple linked ImageTool windows, by
default `True`.
use_manager
Whether to open the ImageTool windows using the ImageToolManager if it is
running, by default `True`.
execute
Whether to execute the Qt event loop and display the window, by default `None`.
If `None`, the execution is determined based on the current IPython shell.
If `None`, the execution is determined based on the current IPython shell. This
argument has no effect if the ImageToolManager is running and `use_manager` is
set to `True`.
**kwargs
Additional keyword arguments to be passed onto the underlying slicer area. For a
full list of supported arguments, see the
Expand All @@ -93,37 +116,40 @@ def itool(
>>> itool(data, cmap="gray", gamma=0.5)
>>> itool(data_list, link=True)
"""
if use_manager:
from erlab.interactive.imagetool.manager import is_running

if not is_running():
use_manager = False

if use_manager:
from erlab.interactive.imagetool.manager import show_in_manager

return show_in_manager(data, link=link, link_colors=link_colors, **kwargs)

qapp = QtWidgets.QApplication.instance()
if not qapp:
qapp = QtWidgets.QApplication(sys.argv)

if isinstance(qapp, QtWidgets.QApplication):
qapp.setStyle("Fusion")

if isinstance(data, xr.Dataset):
data = [d for d in data.data_vars.values() if d.ndim >= 2 and d.ndim <= 4]
if len(data) == 0:
raise ValueError("No valid data for ImageTool found in the Dataset")

if isinstance(data, np.ndarray | xr.DataArray):
data = (data,)

itool_list = [ImageTool(d, **kwargs) for d in data]
itool_list = [ImageTool(d, **kwargs) for d in _parse_input(data)]

for w in itool_list:
w.show()

if len(itool_list) == 0:
raise ValueError("No data provided")

itool_list[-1].activateWindow()
itool_list[-1].raise_()

if link:
linker = SlicerLinkProxy( # noqa: F841
*[w.slicer_area for w in itool_list], link_colors=link_colors
)

itool_list[-1].activateWindow()
itool_list[-1].raise_()

if execute is None:
execute = True
try:
Expand All @@ -137,7 +163,9 @@ def itool(
pass

if execute:
qapp.exec()
if isinstance(qapp, QtWidgets.QApplication):

Check warning on line 166 in src/erlab/interactive/imagetool/__init__.py

View check run for this annotation

Codecov / codecov/patch

src/erlab/interactive/imagetool/__init__.py#L166

Added line #L166 was not covered by tests
qapp.exec()

del itool_list
gc.collect()

Expand Down
16 changes: 13 additions & 3 deletions src/erlab/interactive/imagetool/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,15 @@ def __init__(self, *slicers: ImageSlicerArea, link_colors: bool = True):
def children(self) -> set[ImageSlicerArea]:
return self._children

@property
def num_children(self) -> int:
return len(self._children)

def unlink_all(self):
for s in self._children:
s._linking_proxy = None
self._children.clear()

def add(self, slicer_area: ImageSlicerArea):
if slicer_area.is_linked:
if slicer_area._linking_proxy == self:
Expand Down Expand Up @@ -756,10 +765,10 @@ def connect_signals(self):
self.sigCursorCountChanged.connect(lambda: self.set_colormap(update=True))
self.sigWriteHistory.connect(self.write_state)

def add_link(self, proxy: SlicerLinkProxy):
def link(self, proxy: SlicerLinkProxy):
proxy.add(self)

Check warning on line 769 in src/erlab/interactive/imagetool/core.py

View check run for this annotation

Codecov / codecov/patch

src/erlab/interactive/imagetool/core.py#L769

Added line #L769 was not covered by tests

def remove_link(self):
def unlink(self):
if self.is_linked:
cast(SlicerLinkProxy, self._linking_proxy).remove(self)

Expand Down Expand Up @@ -797,7 +806,6 @@ def refresh_current(self, axes: tuple[int, ...] | None = None):
def refresh(self, cursor: int, axes: tuple[int, ...] | None = None):
self.sigIndexChanged.emit(cursor, axes)

@record_history
def view_all(self):
for ax in self.axes:
ax.vb.enableAutoRange()
Expand Down Expand Up @@ -1420,6 +1428,7 @@ def __init__(
pg.PlotDataItem.__init__(self, axes=axes, cursor=cursor, **kargs)
ItoolDisplayObject.__init__(self, axes=axes, cursor=cursor)
self.is_vertical = is_vertical
self.setCursor(QtGui.QCursor(QtCore.Qt.CursorShape.CrossCursor))

def refresh_data(self):
ItoolDisplayObject.refresh_data(self)
Expand All @@ -1441,6 +1450,7 @@ def __init__(
):
BetterImageItem.__init__(self, axes=axes, cursor=cursor, **kargs)
ItoolDisplayObject.__init__(self, axes=axes, cursor=cursor)
self.setCursor(QtGui.QCursor(QtCore.Qt.CursorShape.CrossCursor))

def updateImage(self, *args, **kargs):
defaults = {"autoLevels": not self.slicer_area.levels_locked}
Expand Down
Loading

0 comments on commit b52d249

Please sign in to comment.