Skip to content

Commit

Permalink
Switch layer transfer to the active layer
Browse files Browse the repository at this point in the history
Well, sometimes. What I've actually done is provide a setting that lets
the user choose whether to just use the active layer or to prompt the
user to choose the layer to be transferred.

I figured this was the best middle ground between the two, especially
since I had implementations of both coded.
  • Loading branch information
gselzer committed Aug 15, 2022
1 parent dc82852 commit 3c5da59
Show file tree
Hide file tree
Showing 5 changed files with 173 additions and 27 deletions.
8 changes: 7 additions & 1 deletion settings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,10 @@
# This can be replaced with an ABSOLUTE path to a local ImageJ2 or Fiji installation.
# If left null, napari-imagej will instead launch a vanilla ImageJ2, downloading
# the latest available ImageJ2 if necessary.
imagej_installation: null
imagej_installation: null

# This can be used to identify whether transferred data between ImageJ2 and napari
# should be selected via activation or by user selection via a dialog.
# By default, the active layer/window is chosen for transfer between applications.
# By setting this value to false, a popup will be shown instead.
choose_active_layer: true
13 changes: 11 additions & 2 deletions src/napari_imagej/setup_imagej.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import logging
import os
import sys
from functools import lru_cache
from multiprocessing.pool import AsyncResult, ThreadPool
from typing import Callable

Expand Down Expand Up @@ -41,8 +42,7 @@ def imagej_init():
log_debug("Completed JVM Configuration")

# Parse imagej settings
settings: dict = yaml.safe_load(open("settings.yml", "r"))
ij_dir = settings.get("imagej_installation", None)
ij_dir = setting("imagej_installation")

_ij = imagej.init(ij_dir_or_version_or_endpoint=ij_dir, mode=get_mode())
log_debug(f"Initialized at version {_ij.getVersion()}")
Expand All @@ -54,6 +54,15 @@ def imagej_init():
return _ij


def setting(value: str):
return settings().get(value, None)


@lru_cache(maxsize=None)
def settings():
return yaml.safe_load(open("settings.yml", "r"))


def _disable_jvm_shutdown_on_gui_quit(ij_instance):
# We can't quit the gui running headlessly!
if running_headless():
Expand Down
66 changes: 56 additions & 10 deletions src/napari_imagej/widget_IJ2.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from enum import Enum
from threading import Thread
from typing import List
from typing import Optional

from magicgui.widgets import request_values
from napari import Viewer
Expand All @@ -11,7 +11,14 @@
from qtpy.QtWidgets import QHBoxLayout, QMessageBox, QPushButton, QWidget

from napari_imagej._module_utils import _get_layers_hack
from napari_imagej.setup_imagej import ensure_jvm_started, ij, jc, running_headless
from napari_imagej.setup_imagej import (
ensure_jvm_started,
ij,
jc,
log_debug,
running_headless,
setting,
)


class GUIWidget(QWidget):
Expand Down Expand Up @@ -44,19 +51,33 @@ def _showUI(self):


class ToIJButton(QPushButton):
def __init__(self, viewer):
def __init__(self, viewer: Viewer):
super().__init__()
self.viewer = viewer

self.setEnabled(False)
icon = QColoredSVGIcon.from_resources("long_right_arrow")
self.setIcon(icon.colored(theme=viewer.theme))
self.setToolTip("Send layers to ImageJ2")
self.clicked.connect(self.send_layers)
self.setToolTip("Export active napari layer to ImageJ2")
if setting("choose_active_layer"):
self.clicked.connect(self.send_active_layer)
else:
self.clicked.connect(self.send_chosen_layer)

def _set_icon(self, path: str):
icon: QIcon = QIcon(QPixmap(path))
self.setIcon(icon)

def send_layers(self):
def send_active_layer(self):
active_layer: Optional[Layer] = self.viewer.layers.selection.active
if active_layer:
name = active_layer.name
data = ij().py.to_java(active_layer.data)
ij().ui().show(name, data)
else:
log_debug("There is no active layer to export to ImageJ2")

def send_chosen_layer(self):
layers: dict = request_values(
title="Send layers to ImageJ2",
layers={"annotation": Layer, "options": {"choices": _get_layers_hack}},
Expand All @@ -77,8 +98,11 @@ def __init__(self, viewer: Viewer):
self.setEnabled(False)
icon = QColoredSVGIcon.from_resources("long_left_arrow")
self.setIcon(icon.colored(theme=viewer.theme))
self.setToolTip("Get layers from ImageJ2")
self.clicked.connect(self.get_layers)
self.setToolTip("Import active ImageJ2 Dataset to napari")
if setting("choose_active_layer"):
self.clicked.connect(self.get_active_layer)
else:
self.clicked.connect(self.get_chosen_layer)

def _set_icon(self, path: str):
icon: QIcon = QIcon(QPixmap(path))
Expand All @@ -89,13 +113,15 @@ def _get_objects(self, t):
compatibleInputs.addAll(ij().object().getObjects(t))
return list(compatibleInputs)

def get_layers(self) -> List[Layer]:
def get_chosen_layer(self) -> None:
images = self._get_objects(jc.RandomAccessibleInterval)
names = [ij().object().getName(i) for i in images]
# Ask the user to pick a layer
choices: dict = request_values(
title="Send layers to ImageJ2",
title="Send layers to napari",
dataset={"annotation": Enum, "options": {"choices": names}},
)
# Parse the returned dict for the Layer selection
if choices is not None:
for _, name in choices.items():
i = names.index(name)
Expand All @@ -108,6 +134,24 @@ def get_layers(self) -> List[Layer]:
else:
raise ValueError(f"{image} cannot be displayed in napari!")

def get_active_layer(self) -> None:
# Choose the active Dataset
image = ij().get("net.imagej.display.ImageDisplayService").getActiveDataset()
if image is None:
log_debug("There is no active window to export to napari")
return
# Get the stuff needed for a new layer
py_image = ij().py.from_java(image)
name = ij().object().getName(image)
# Create and add the layer
if isinstance(py_image, Layer):
py_image.name = name
self.viewer.add_layer(py_image)
elif ij().py._is_arraylike(py_image):
self.viewer.add_image(data=py_image, name=name)
else:
raise ValueError(f"{image} cannot be displayed in napari!")


class GUIButton(QPushButton):
def __init__(self):
Expand All @@ -126,11 +170,13 @@ def _set_icon(self, path: str):
def _setup_headful(self):
self._set_icon("resources/16x16-flat-disabled.png")
self.setToolTip("Display ImageJ2 GUI (loading)")

def post_setup():
ensure_jvm_started()
self._set_icon("resources/16x16-flat.png")
self.setEnabled(True)
self.setToolTip("Display ImageJ2 GUI")

Thread(target=post_setup).start()

def _setup_headless(self):
Expand Down
19 changes: 18 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import pytest
from napari import Viewer

import napari_imagej.widget_IJ2
from napari_imagej.widget import ImageJWidget
from napari_imagej.widget_IJ2 import GUIWidget

Expand All @@ -17,7 +18,6 @@ def ij():
@pytest.fixture
def imagej_widget(make_napari_viewer) -> Generator[ImageJWidget, None, None]:
# Create widget

viewer: Viewer = make_napari_viewer()
ij_widget: ImageJWidget = ImageJWidget(viewer)

Expand All @@ -30,7 +30,24 @@ def imagej_widget(make_napari_viewer) -> Generator[ImageJWidget, None, None]:
@pytest.fixture
def gui_widget(make_napari_viewer) -> Generator[GUIWidget, None, None]:
# Create widget
viewer: Viewer = make_napari_viewer()
widget: GUIWidget = GUIWidget(viewer)

yield widget

# Cleanup -> Close the widget, trigger ImageJ shutdown
widget.close()


@pytest.fixture
def gui_widget_chooser(make_napari_viewer) -> Generator[GUIWidget, None, None]:
# monkeypatch settings
def mock_setting(value: str):
return {"imagej_installation": None, "choose_active_layer": False}[value]

napari_imagej.widget_IJ2.setting = mock_setting

# Create widget
viewer: Viewer = make_napari_viewer()
widget: GUIWidget = GUIWidget(viewer)

Expand Down
94 changes: 81 additions & 13 deletions tests/test_IJ2GUIWidget.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from threading import Thread
from typing import Callable
import numpy
import pytest
from napari.layers import Image
from napari.viewer import current_viewer
from qtpy.QtCore import Qt, QTimer
from qtpy.QtGui import QPixmap
from qtpy.QtWidgets import QApplication, QDialog, QMessageBox, QPushButton, QHBoxLayout
from qtpy.QtWidgets import QApplication, QDialog, QHBoxLayout, QMessageBox, QPushButton

from napari_imagej.setup_imagej import JavaClasses, running_headless
from napari_imagej.widget_IJ2 import FromIJButton, GUIButton, GUIWidget, ToIJButton
Expand Down Expand Up @@ -63,16 +65,21 @@ def test_widget_layout(gui_widget: GUIWidget):
subwidgets = gui_widget.children()
assert len(subwidgets) == 4
assert isinstance(subwidgets[0], QHBoxLayout)

assert isinstance(subwidgets[1], FromIJButton)
assert not subwidgets[1].isEnabled()

assert isinstance(subwidgets[2], ToIJButton)
assert not subwidgets[2].isEnabled()

assert isinstance(subwidgets[3], GUIButton)


@pytest.mark.skipif(
running_headless(), reason="Only applies when not running headlessly"
)
def test_GUIButton_layout_headful(qtbot, ij, gui_widget: GUIWidget):
"""Tests headless-specific settings of GUIButton"""
"""Tests headful-specific settings of GUIButton"""
button: GUIButton = gui_widget.gui_button

expected: QPixmap = QPixmap("resources/16x16-flat.png")
Expand All @@ -90,6 +97,20 @@ def test_GUIButton_layout_headful(qtbot, ij, gui_widget: GUIWidget):
qtbot.waitUntil(ij.ui().isVisible)


def _button_popup_handler(qtbot, button: QPushButton, handler: Callable[[], None]) -> Thread:
# Grab the original window
original = QApplication.activeWindow()
# Start the handler in a new thread
t: Thread = Thread(target=handler)
t.start()
# Click the button to trigger the popup
qtbot.mouseClick(button, Qt.LeftButton, delay=1)
# Wait for the handler function to complete
t.join()
# Assert that we are back to the original window i.e. that the popup was handled
qtbot.waitUntil(lambda: QApplication.activeWindow() == original)


@pytest.mark.skipif(
not running_headless(), reason="Only applies when running headlessly"
)
Expand All @@ -108,6 +129,7 @@ def test_GUIButton_layout_headless(qtbot, gui_widget: GUIWidget):

# Test popup when running headlessly
def handle_dialog():
qtbot.waitUntil(lambda: isinstance(QApplication.activeWindow(), QMessageBox))
msg = QApplication.activeWindow()
expected_text = (
"The ImageJ2 user interface cannot be opened "
Expand All @@ -120,20 +142,17 @@ def handle_dialog():
assert Qt.RichText == msg.textFormat()
assert Qt.TextBrowserInteraction == msg.textInteractionFlags()

assert isinstance(msg, QMessageBox)
ok_button = msg.button(QMessageBox.Ok)
qtbot.mouseClick(ok_button, Qt.LeftButton, delay=1)

original = QApplication.activeWindow()
QTimer.singleShot(100, handle_dialog)
qtbot.mouseClick(button, Qt.LeftButton, delay=1)
qtbot.waitUntil(lambda: QApplication.activeWindow() == original)
# Handle the popup from the button click
_button_popup_handler(qtbot, button, handler=handle_dialog)


@pytest.mark.skipif(
running_headless(), reason="Only applies when not running headlessly"
)
def test_data_to_ImageJ(qtbot, ij, gui_widget: GUIWidget):
def test_active_data_send(qtbot, ij, gui_widget: GUIWidget):
button: ToIJButton = gui_widget.to_ij
assert not button.isEnabled()

Expand All @@ -146,6 +165,57 @@ def test_data_to_ImageJ(qtbot, ij, gui_widget: GUIWidget):
image: Image = Image(data=sample_data, name="test_to")
current_viewer().add_layer(image)

# Press the button, handle the Dialog
qtbot.mouseClick(button, Qt.LeftButton, delay=1)

# Assert that the data is now in Fiji
active_display = ij.display().getActiveDisplay()
assert isinstance(active_display, jc.ImageDisplay)
assert "test_to" == active_display.getName()


@pytest.mark.skipif(
running_headless(), reason="Only applies when not running headlessly"
)
def test_active_data_receive(qtbot, ij, gui_widget: GUIWidget):
button: FromIJButton = gui_widget.from_ij
assert not button.isEnabled()

# Show the button
qtbot.mouseClick(gui_widget.gui_button, Qt.LeftButton, delay=1)
qtbot.waitUntil(lambda: button.isEnabled())

# Add some data to ImageJ
sample_data = jc.ArrayImgs.bytes(10, 10, 10)
ij.ui().show("test_from", sample_data)

# Press the button, handle the Dialog
assert 0 == len(button.viewer.layers)
qtbot.mouseClick(button, Qt.LeftButton, delay=1)

# Assert that the data is now in napari
assert 1 == len(button.viewer.layers)
layer = button.viewer.layers[0]
assert isinstance(layer, Image)
assert (10, 10, 10) == layer.data.shape


@pytest.mark.skipif(
running_headless(), reason="Only applies when not running headlessly"
)
def test_chosen_data_send(qtbot, ij, gui_widget_chooser):
button: ToIJButton = gui_widget_chooser.to_ij
assert not button.isEnabled()

# Show the button
qtbot.mouseClick(gui_widget_chooser.gui_button, Qt.LeftButton, delay=1)
qtbot.waitUntil(lambda: button.isEnabled())

# Add some data to the viewer
sample_data = numpy.ones((100, 100, 3))
image: Image = Image(data=sample_data, name="test_to")
current_viewer().add_layer(image)

def handle_dialog():
qtbot.waitUntil(lambda: isinstance(QApplication.activeWindow(), QDialog))
dialog = QApplication.activeWindow()
Expand All @@ -158,8 +228,7 @@ def handle_dialog():
pytest.fail("Could not find the Ok button!")

# Press the button, handle the Dialog
QTimer.singleShot(100, handle_dialog)
qtbot.mouseClick(button, Qt.LeftButton, delay=1)
_button_popup_handler(qtbot, button, handler=handle_dialog)

# Assert that the data is now in Fiji
active_display = ij.display().getActiveDisplay()
Expand All @@ -170,7 +239,7 @@ def handle_dialog():
@pytest.mark.skipif(
running_headless(), reason="Only applies when not running headlessly"
)
def test_data_from_ImageJ(qtbot, ij, gui_widget: GUIWidget):
def test_chosen_data_receive(qtbot, ij, gui_widget: GUIWidget):
button: FromIJButton = gui_widget.from_ij
assert not button.isEnabled()

Expand All @@ -195,8 +264,7 @@ def handle_dialog():

# Press the button, handle the Dialog
assert 0 == len(button.viewer.layers)
QTimer.singleShot(100, handle_dialog)
qtbot.mouseClick(button, Qt.LeftButton, delay=1)
_button_popup_handler(qtbot, button, handler=handle_dialog)

# Assert that the data is now in napari
assert 1 == len(button.viewer.layers)
Expand Down

0 comments on commit 3c5da59

Please sign in to comment.