Skip to content

Commit 54cf2f3

Browse files
authored
Merge pull request #301 from imagej/global-gateway
Add callback mechanism for GUI mode
2 parents ef57503 + 45c1448 commit 54cf2f3

10 files changed

+241
-148
lines changed

conftest.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def pytest_addoption(parser):
3434

3535

3636
@pytest.fixture(scope="session")
37-
def ij_fixture(request):
37+
def ij(request):
3838
"""
3939
Create an ImageJ instance to be used by the whole testing environment
4040
:param request: Pytest variable passed in to fixtures
@@ -43,12 +43,14 @@ def ij_fixture(request):
4343
legacy = request.config.getoption("--legacy")
4444
headless = request.config.getoption("--headless")
4545

46+
imagej.when_imagej_starts(lambda ij: setattr(ij, "_testing", True))
47+
4648
mode = "headless" if headless else "interactive"
47-
ij_wrapper = imagej.init(ij_dir, mode=mode, add_legacy=legacy)
49+
ij = imagej.init(ij_dir, mode=mode, add_legacy=legacy)
4850

49-
yield ij_wrapper
51+
yield ij
5052

51-
ij_wrapper.dispose()
53+
ij.dispose()
5254

5355

5456
def str2bool(v):

doc/05-Convenience-methods-of-PyImageJ.ipynb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,31 @@
129129
"source": [
130130
"Note the warnings! We're currently in headless mode. The many legacy ImageJ functions operate limitedly or not at all in headless mode. For example the `RoiManager` is not functional in a true headless enviornment."
131131
]
132+
},
133+
{
134+
"cell_type": "markdown",
135+
"metadata": {},
136+
"source": [
137+
"## 5.2 Register functions to start with ImageJ\n",
138+
"\n",
139+
"Functions can be executed during ImageJ's initialization routine by registering the functions with PyImageJ's callback mechanism `when_imagej_starts()`. This is particularly useful for macOS users in `gui` mode, allowing functions to be called before the Python [REPL/interpreter](https://docs.python.org/3/tutorial/interpreter.html) is [blocked](Initialization.md/#gui-mode).\n",
140+
"\n",
141+
"The following example uses `when_imagej_starts()` callback display a to `uint16` 2D NumPy array it with ImageJ's viewer, print it's dimensions (_i.e._ shape) and open the `RoiManager` while ImageJ initializes.\n",
142+
"\n",
143+
"```python\n",
144+
"import imagej\n",
145+
"import numpy as np\n",
146+
"\n",
147+
"# register functions\n",
148+
"arr = np.random.randint(0, 2**16, size=(256, 256), dtype=np.uint16) # create random 16-bit array\n",
149+
"imagej.when_imagej_starts(lambda ij: ij.RoiManager.getRoiManager()) # open the RoiManager\n",
150+
"imagej.when_imagej_starts(lambda ij: ij.ui().show(ij.py.to_dataset(arr))) # convert and display the array\n",
151+
"imagej.when_imagej_starts(lambda _: print(f\"array shape: {arr.shape}\"))\n",
152+
"\n",
153+
"# initialize imagej\n",
154+
"ij = imagej.init(mode='interactive')\n",
155+
"```"
156+
]
132157
}
133158
],
134159
"metadata": {

src/imagej/__init__.py

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import sys
3939
import threading
4040
import time
41+
from ctypes import cdll
4142
from enum import Enum
4243
from functools import lru_cache
4344
from pathlib import Path
@@ -60,7 +61,8 @@
6061
__version__ = sj.get_version("pyimagej")
6162

6263
_logger = logging.getLogger(__name__)
63-
rai_lock = threading.Lock()
64+
_init_callbacks = []
65+
_rai_lock = threading.Lock()
6466

6567
# Enable debug logging if DEBUG environment variable is set.
6668
try:
@@ -1007,14 +1009,14 @@ def _op(self):
10071009
def _ra(self):
10081010
threadLocal = getattr(self, "_threadLocal", None)
10091011
if threadLocal is None:
1010-
with rai_lock:
1012+
with _rai_lock:
10111013
threadLocal = getattr(self, "_threadLocal", None)
10121014
if threadLocal is None:
10131015
threadLocal = threading.local()
10141016
self._threadLocal = threadLocal
10151017
ra = getattr(threadLocal, "ra", None)
10161018
if ra is None:
1017-
with rai_lock:
1019+
with _rai_lock:
10181020
ra = getattr(threadLocal, "ra", None)
10191021
if ra is None:
10201022
ra = self.randomAccess()
@@ -1205,19 +1207,39 @@ def init(
12051207
macos = sys.platform == "darwin"
12061208

12071209
if macos and mode == Mode.INTERACTIVE:
1208-
raise EnvironmentError("Sorry, the interactive mode is not available on macOS.")
1210+
# check for main thread only on macOS
1211+
if _macos_is_main_thread():
1212+
raise EnvironmentError(
1213+
"Sorry, the interactive mode is not available on macOS."
1214+
)
12091215

12101216
if not sj.jvm_started():
12111217
success = _create_jvm(ij_dir_or_version_or_endpoint, mode, add_legacy)
12121218
if not success:
12131219
raise RuntimeError("Failed to create a JVM with the requested environment.")
12141220

1221+
def run_callbacks(ij):
1222+
# invoke registered callback functions
1223+
for callback in _init_callbacks:
1224+
callback(ij)
1225+
return ij
1226+
12151227
if mode == Mode.GUI:
12161228
# Show the GUI and block.
1229+
global gateway
1230+
gateway = None
1231+
1232+
def show_gui_and_run_callbacks():
1233+
global gateway
1234+
gateway = _create_gateway()
1235+
gateway.ui().showUI()
1236+
run_callbacks(gateway)
1237+
return gateway
1238+
12171239
if macos:
12181240
# NB: This will block the calling (main) thread forever!
12191241
try:
1220-
setupGuiEnvironment(lambda: _create_gateway().ui().showUI())
1242+
setupGuiEnvironment(show_gui_and_run_callbacks)
12211243
except ModuleNotFoundError as e:
12221244
if e.msg == "No module named 'PyObjCTools'":
12231245
advice = (
@@ -1237,16 +1259,34 @@ def init(
12371259
raise
12381260
else:
12391261
# Create and show the application.
1240-
gateway = _create_gateway()
1241-
gateway.ui().showUI()
1262+
gateway = show_gui_and_run_callbacks()
12421263
# We are responsible for our own blocking.
12431264
# TODO: Poll using something better than ui().isVisible().
1244-
while gateway.ui().isVisible():
1265+
while sj.jvm_started() and gateway.ui().isVisible():
12451266
time.sleep(1)
1246-
return None
1247-
else:
1248-
# HEADLESS or INTERACTIVE mode: create the gateway and return it.
1249-
return _create_gateway()
1267+
1268+
del gateway
1269+
return None
1270+
1271+
# HEADLESS or INTERACTIVE mode: create the gateway and return it.
1272+
return run_callbacks(_create_gateway())
1273+
1274+
1275+
def when_imagej_starts(f) -> None:
1276+
"""
1277+
Registers a function to be called immediately after ImageJ2 starts.
1278+
This is useful especially with GUI mode, to perform additional
1279+
configuration and operations following initialization of ImageJ2,
1280+
because the use of GUI mode blocks the calling thread indefinitely.
1281+
1282+
:param f: Single-argument function to invoke during imagej.init().
1283+
The function will be passed the newly created ImageJ2 Gateway
1284+
as its sole argument, and called as the final action of the
1285+
init function before it returns or blocks.
1286+
"""
1287+
# Add function to the list of callbacks to invoke upon start_jvm().
1288+
global _init_callbacks
1289+
_init_callbacks.append(f)
12501290

12511291

12521292
def imagej_main():
@@ -1484,6 +1524,27 @@ def _includes_imagej_legacy(items: list):
14841524
return any(item.startswith("net.imagej:imagej-legacy") for item in items)
14851525

14861526

1527+
def _macos_is_main_thread():
1528+
"""Detect if the current thread is the main thread on macOS.
1529+
1530+
:return: Boolean indicating if the current thread is the main thread.
1531+
"""
1532+
# try to load the pthread library
1533+
try:
1534+
pthread = cdll.LoadLibrary("libpthread.dylib")
1535+
except OSError as exc:
1536+
_log_exception(_logger, exc)
1537+
print("No pthread library found.")
1538+
# assume the current thread is the main thread
1539+
return True
1540+
1541+
# detect if the current thread is the main thread
1542+
if pthread.pthread_main_np() == 1:
1543+
return True
1544+
else:
1545+
return False
1546+
1547+
14871548
def _set_ij_env(ij_dir):
14881549
"""
14891550
Create a list of required jars and add to the java classpath.

tests/test_callbacks.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
def test_when_imagej_starts(ij):
2+
"""
3+
The ImageJ2 gateway test fixture registers a callback function via
4+
when_imagej_starts, which injects a small piece of data into the gateway
5+
object. We check for that data here to make sure the callback happened.
6+
"""
7+
assert True is getattr(ij, "_testing", None)

tests/test_ctypes.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,14 @@
2929

3030

3131
@pytest.mark.parametrize(argnames="ctype,jtype_str,value", argvalues=parameters)
32-
def test_ctype_to_realtype(ij_fixture, ctype, jtype_str, value):
32+
def test_ctype_to_realtype(ij, ctype, jtype_str, value):
3333
py_type = ctype(value)
3434
# Convert the ctype into a RealType
35-
converted = ij_fixture.py.to_java(py_type)
35+
converted = ij.py.to_java(py_type)
3636
jtype = sj.jimport(jtype_str)
3737
assert isinstance(converted, jtype)
3838
assert converted.get() == value
3939
# Convert the RealType back into a ctype
40-
converted_back = ij_fixture.py.from_java(converted)
40+
converted_back = ij.py.from_java(converted)
4141
assert isinstance(converted_back, ctype)
4242
assert converted_back.value == value

tests/test_fiji.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,23 @@
44
# -- Tests --
55

66

7-
def test_plugins_load_using_pairwise_stitching(ij_fixture):
7+
def test_plugins_load_using_pairwise_stitching(ij):
88
try:
99
sj.jimport("plugin.Stitching_Pairwise")
1010
except TypeError:
1111
pytest.skip("No Pairwise Stitching plugin available. Skipping test.")
1212

13-
if not ij_fixture.legacy:
13+
if not ij.legacy:
1414
pytest.skip("No original ImageJ. Skipping test.")
15-
if ij_fixture.ui().isHeadless():
15+
if ij.ui().isHeadless():
1616
pytest.skip("No GUI. Skipping test.")
1717

18-
tile1 = ij_fixture.IJ.createImage("Tile1", "8-bit random", 512, 512, 1)
19-
tile2 = ij_fixture.IJ.createImage("Tile2", "8-bit random", 512, 512, 1)
18+
tile1 = ij.IJ.createImage("Tile1", "8-bit random", 512, 512, 1)
19+
tile2 = ij.IJ.createImage("Tile2", "8-bit random", 512, 512, 1)
2020
args = {"first_image": tile1.getTitle(), "second_image": tile2.getTitle()}
21-
ij_fixture.py.run_plugin("Pairwise stitching", args)
22-
result_name = ij_fixture.WindowManager.getCurrentImage().getTitle()
21+
ij.py.run_plugin("Pairwise stitching", args)
22+
result_name = ij.WindowManager.getCurrentImage().getTitle()
2323

24-
ij_fixture.IJ.run("Close All", "")
24+
ij.IJ.run("Close All", "")
2525

2626
assert result_name == "Tile1<->Tile2"

0 commit comments

Comments
 (0)