From 139bc570aa9982f337801c96e180d4748c503c0f Mon Sep 17 00:00:00 2001 From: Henry Pinkard <7969470+henrypinkard@users.noreply.github.com> Date: Thu, 7 Sep 2023 13:07:44 -0700 Subject: [PATCH 1/2] documentation improvements, examples, bug fixes, and some tests for notification API --- docs/source/advanced_usage.rst | 2 +- docs/source/apis.rst | 8 +++ docs/source/headless_mode.rst | 10 ++-- docs/source/index.rst | 4 +- pycromanager/__init__.py | 1 + pycromanager/acq_future.py | 36 ++++++++----- pycromanager/acquisition/acq_constructor.py | 2 +- .../acq_eng_py/main/acq_notification.py | 43 +++++++++------ .../acquisition/acquisition_superclass.py | 2 +- .../acquisition/java_backend_acquisitions.py | 15 +++--- .../python_backend_acquisitions.py | 2 +- pycromanager/headless.py | 9 ++-- pycromanager/test/test_notifications.py | 54 +++++++++++++++++++ scripts/adaptive_acquisiton_api.py | 29 ++++++++++ scripts/notification_printer.py | 16 ++++++ scripts/python_backend.py | 11 ++++ 16 files changed, 196 insertions(+), 48 deletions(-) create mode 100644 pycromanager/test/test_notifications.py create mode 100644 scripts/adaptive_acquisiton_api.py create mode 100644 scripts/notification_printer.py create mode 100644 scripts/python_backend.py diff --git a/docs/source/advanced_usage.rst b/docs/source/advanced_usage.rst index 58b4e980..3dd08d9e 100644 --- a/docs/source/advanced_usage.rst +++ b/docs/source/advanced_usage.rst @@ -16,7 +16,7 @@ Advanced use cases may require a deeper understanding of how pycromanager works. :width: 800 :alt: Architecture of pycro-manager - **Headless mode** In headless mode, pycro-manager can be run without the micro-manager user interface, enabling the implementation of custom user interfaces or no user interfaces at all. + **Headless mode (using Java backend)** In headless mode, pycro-manager can be run without the micro-manager user interface, enabling the implementation of custom user interfaces or no user interfaces at all. .. toctree:: diff --git a/docs/source/apis.rst b/docs/source/apis.rst index 3783b467..b87298b6 100644 --- a/docs/source/apis.rst +++ b/docs/source/apis.rst @@ -108,10 +108,18 @@ MagellanAcquisition .. autoclass:: MagellanAcquisition :members: + +Headless mode +####################################################### + start_headless =========================== .. autofunction:: start_headless +stop_headless +=========================== +.. autofunction:: stop_headless + Dataset ############################################## diff --git a/docs/source/headless_mode.rst b/docs/source/headless_mode.rst index 4dcccf75..0d4014b1 100644 --- a/docs/source/headless_mode.rst +++ b/docs/source/headless_mode.rst @@ -4,7 +4,11 @@ Headless Mode ************************** -Headless mode allows you to use Pycro-manager without having to launch Micro-manager. The :meth:`start_headless` method should be run prior to any other calls. This will launch in the background with only the essential Java components for running the Pycro-manager acquisition system, without showing a user interface. It provides a lightweight environment that can also be run without a GUI in order to use Pycro-manager as a hidden backend for custom applications. This could be useful, for example, if you want to implement your own user interface, or run Pycro-manager from a server environment. +Headless mode allows you to use Pycro-manager without having to launch Micro-manager. The :meth:`start_headless` method should be run prior to any other calls. This function launches Micro-Manager core and the acqusition engine, which is used by the ``Acquisition`` class. + +Headless mode can be run with either a Java backend (i.e. the same components that run when you use pycromanager along with the Micro-Manager application) or a Python backend, which provides pure python versions of the same components. + +Headless mode provides a lightweight environment that can also be run without a GUI in order to use Pycro-manager as a hidden backend for custom applications. This could be useful, for example, if you want to implement your own user interface, or run Pycro-manager from a server environment. .. code-block:: python @@ -20,7 +24,7 @@ Headless mode allows you to use Pycro-manager without having to launch Micro-man core = Core() -The example below shows headless mode in combination with an saved image callback, which calls a user-defined function whenever new data is written to disk. This setup could be used to replace the pycro-manager viewer with a custom user interface (note the ``show_display=False`` in the acquisition). +The example below shows headless mode in combination with an saved image callback, which calls a user-defined function whenever new data is stored (on disk or in RAM depending on the arguments to ``Acquisitoin``). This setup could be used to replace the pycro-manager viewer with a custom user interface (note the ``show_display=False`` in the acquisition). .. code-block:: python @@ -55,7 +59,7 @@ The example below shows headless mode in combination with an saved image callbac How to install Java for Mac OS ============================================= -Running headless mode is easy on Windows, because the correct version of Java comes bundled with the Micro-Manager installer. However, on Mac OS, this is not the case. As a result, it can be helpful to manually install a compatible version of Java. +Running headless mode with the Java backend is easy on Windows, because the correct version of Java comes bundled with the Micro-Manager installer. However, on Mac OS, this is not the case. As a result, it can be helpful to manually install a compatible version of Java. This can be done through Python as follows: First install the Python package ``install-jdk``. diff --git a/docs/source/index.rst b/docs/source/index.rst index b38eb139..66a90e8e 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -2,9 +2,9 @@ :width: 600 :alt: Alternative text -``pycromanager`` is a python package for microscope hardware automation and image acquisition. It provides a variety of components that can be used independently or in combination, with the aim of being flexible enough to enable a variety of applications. +``pycromanager`` is a python package for microscope hardware automation and image acquisition. It provides many components that can be used independently or in combination, with the aim of being flexible enough to enable a variety of applications. -The best place to get started is the :doc:`user_guide`. The :doc:`tutorials` section shows jupyter notebook tutorials of using pycro-manager vs various applications, as well as some more in depth descriptions of certain features. +The best place to get started is the :doc:`user_guide`. The :doc:`tutorials` section shows jupyter notebook tutorials of using pycro-manager for various applications, as well as some more in depth descriptions of certain features. Information on the original motivations for Pycro-Manager can be found in the `publication `_, though its capabilities have since expanded. diff --git a/pycromanager/__init__.py b/pycromanager/__init__.py index 242f02aa..97306420 100644 --- a/pycromanager/__init__.py +++ b/pycromanager/__init__.py @@ -7,4 +7,5 @@ from pycromanager.mm_java_classes import Studio, Magellan from pycromanager.core import Core from pycromanager.zmq_bridge.wrappers import JavaObject, JavaClass, PullSocket, PushSocket +from pycromanager.acquisition.acq_eng_py.main.acq_notification import AcqNotification from ._version import __version__, version_info diff --git a/pycromanager/acq_future.py b/pycromanager/acq_future.py index d4d0f581..36d9804a 100644 --- a/pycromanager/acq_future.py +++ b/pycromanager/acq_future.py @@ -1,10 +1,9 @@ import threading from pycromanager.acquisition.acq_eng_py.main.acq_notification import AcqNotification -def _axes_to_key(axes_or_axes_list): +def _axes_to_key(axes): """ Turn axes into a hashable key """ - return frozenset(axes_or_axes_list.items()) - + return frozenset(axes.items()) class AcquisitionFuture: @@ -38,12 +37,18 @@ def _notify(self, notification): received. Want to store this, rather than just waiting around for it, in case the await methods are called after the notification has already been sent. """ - if notification.type == AcqNotification.Acquisition.ACQ_EVENTS_FINISHED: + if notification.phase == AcqNotification.Acquisition.ACQ_EVENTS_FINISHED or \ + notification.phase == AcqNotification.Image.DATA_SINK_FINISHED: return # ignore for now... - key = _axes_to_key(notification.axes) - if key not in self._notification_recieved.keys(): + if isinstance(notification.id, list): + keys = [_axes_to_key(ax) for ax in notification.id] + else: + keys = [_axes_to_key(notification.id)] + # check if any keys are present in the notification_recieved dict + if not any([key in self._notification_recieved.keys() for key in keys]): return # ignore notifications that aren't relevant to this future - self._notification_recieved[key][notification.phase] = True + for key in keys: + self._notification_recieved[key][notification.phase] = True with self._condition: self._condition.notify_all() @@ -57,11 +62,18 @@ def await_execution(self, axes, phase): self._condition.wait() def await_image_saved(self, axes, return_image=False): - key = _axes_to_key(axes) - with self._condition: - while not self._notification_recieved[key][AcqNotification.Image.IMAGE_SAVED]: - self._condition.wait() + if isinstance(axes, list): + keys = [_axes_to_key(ax) for ax in axes] + else: + keys = [_axes_to_key(axes)] + for key in keys: + with self._condition: + while not self._notification_recieved[key][AcqNotification.Image.IMAGE_SAVED]: + self._condition.wait() if return_image: - return self._acq.get_dataset().read_image(**axes) + if isinstance(axes, list): + return [self._acq.get_dataset().read_image(**ax) for ax in axes] + else: + return self._acq.get_dataset().read_image(**axes) diff --git a/pycromanager/acquisition/acq_constructor.py b/pycromanager/acquisition/acq_constructor.py index 6358c358..8a486419 100644 --- a/pycromanager/acquisition/acq_constructor.py +++ b/pycromanager/acquisition/acq_constructor.py @@ -11,7 +11,7 @@ class Acquisition(PycromanagerAcquisitionBase): def __new__(cls, directory: str = None, - name: str = None, + name: str = 'default_acq_name', image_process_fn: callable = None, event_generation_hook_fn: callable = None, pre_hardware_hook_fn: callable = None, diff --git a/pycromanager/acquisition/acq_eng_py/main/acq_notification.py b/pycromanager/acquisition/acq_eng_py/main/acq_notification.py index 03da92c7..6358cc8b 100644 --- a/pycromanager/acquisition/acq_eng_py/main/acq_notification.py +++ b/pycromanager/acquisition/acq_eng_py/main/acq_notification.py @@ -1,3 +1,5 @@ +import json + class AcqNotification: class Acquisition: @@ -6,7 +8,7 @@ class Acquisition: @staticmethod def to_string(): - return "Global" + return "global" class Hardware: PRE_HARDWARE = "pre_hardware" @@ -14,7 +16,7 @@ class Hardware: @staticmethod def to_string(): - return "Hardware" + return "hardware" class Camera: PRE_SEQUENCE_STARTED = "pre_sequence_started" @@ -23,7 +25,7 @@ class Camera: @staticmethod def to_string(): - return "Camera" + return "camera" class Image: IMAGE_SAVED = "image_saved" @@ -31,23 +33,30 @@ class Image: @staticmethod def to_string(): - return "Image" + return "image" def __init__(self, type, id, phase=None): - if type is None: - # then figure it out based on the phase - if phase in [AcqNotification.Camera.PRE_SNAP, AcqNotification.Camera.POST_EXPOSURE, - AcqNotification.Camera.PRE_SEQUENCE_STARTED]: - type = AcqNotification.Camera - elif phase in [AcqNotification.Hardware.PRE_HARDWARE, AcqNotification.Hardware.POST_HARDWARE]: - type = AcqNotification.Hardware - elif phase == AcqNotification.Image.IMAGE_SAVED: - type = AcqNotification.Image - else: - raise ValueError("Unknown phase") - self.type = type + if type == AcqNotification.Acquisition.to_string(): + self.type = AcqNotification.Acquisition + self.id = id + self.phase = phase + elif type == AcqNotification.Image.to_string() and phase == AcqNotification.Image.DATA_SINK_FINISHED: + self.type = AcqNotification.Image + self.id = id + self.phase = phase + elif phase in [AcqNotification.Camera.PRE_SNAP, AcqNotification.Camera.POST_EXPOSURE, + AcqNotification.Camera.PRE_SEQUENCE_STARTED]: + self.type = AcqNotification.Camera + self.id = json.loads(id) + elif phase in [AcqNotification.Hardware.PRE_HARDWARE, AcqNotification.Hardware.POST_HARDWARE]: + self.type = AcqNotification.Hardware + self.id = json.loads(id) + elif phase == AcqNotification.Image.IMAGE_SAVED: + self.type = AcqNotification.Image + self.id = id + else: + raise ValueError("Unknown phase") self.phase = phase - self.id = id @staticmethod diff --git a/pycromanager/acquisition/acquisition_superclass.py b/pycromanager/acquisition/acquisition_superclass.py index 4a8ed5e8..884e2ba9 100644 --- a/pycromanager/acquisition/acquisition_superclass.py +++ b/pycromanager/acquisition/acquisition_superclass.py @@ -80,7 +80,7 @@ def __init__( include various stages of the control of hardware and the camera and saving of images. Notification callbacks will execute asynchronously with respect to the acquisition process. The supplied function should take a single argument, which will be an AcqNotification object. It should execute quickly, - so as to not back up the processing of other notifications. + so as to not back up the processing of other notifications. image_saved_fn : Callable function that takes two arguments (the Axes of the image that just finished saving, and the Dataset) or three arguments (Axes, Dataset and the event_queue) and gets called whenever a new image is written to diff --git a/pycromanager/acquisition/java_backend_acquisitions.py b/pycromanager/acquisition/java_backend_acquisitions.py index 0257adc0..67013717 100644 --- a/pycromanager/acquisition/java_backend_acquisitions.py +++ b/pycromanager/acquisition/java_backend_acquisitions.py @@ -190,11 +190,16 @@ def _notification_handler_fn(acquisition, notification_push_port, connected_even while True: message = monitor_socket.receive() notification = AcqNotification.from_json(message) - acquisition._notification_queue.put(notification) # these are processed seperately to handle image saved callback if AcqNotification.is_image_saved_notification(notification): + if not notification.is_data_sink_finished_notification(): + # decode the NDTiff index entry + index_entry = notification.id.encode('ISO-8859-1') + axes = acquisition._dataset._add_index_entry(index_entry) + # swap the notification id from the byte array of index information to axes + notification.id = axes acquisition._image_notification_queue.put(notification) - + acquisition._notification_queue.put(notification) if AcqNotification.is_acquisition_finished_notification(notification): events_finished = True elif AcqNotification.is_data_sink_finished_notification(notification): @@ -218,7 +223,7 @@ class JavaBackendAcquisition(Acquisition, metaclass=NumpyDocstringInheritanceMet def __init__( self, directory: str=None, - name: str=None, + name: str='default_acq_name', image_process_fn : callable=None, event_generation_hook_fn: callable=None, pre_hardware_hook_fn: callable=None, @@ -439,11 +444,9 @@ def _storage_monitor_fn(): image_notification = self._image_notification_queue.get() if AcqNotification.is_data_sink_finished_notification(image_notification): break - index_entry = image_notification.id.encode('ISO-8859-1') - axes = dataset._add_index_entry(index_entry) dataset._new_image_arrived = True if callback is not None: - callback(axes, dataset) + callback(image_notification.id, dataset) t = threading.Thread(target=_storage_monitor_fn, name='StorageMonitorThread') t.start() return t diff --git a/pycromanager/acquisition/python_backend_acquisitions.py b/pycromanager/acquisition/python_backend_acquisitions.py index 2e01a73c..d34292dd 100644 --- a/pycromanager/acquisition/python_backend_acquisitions.py +++ b/pycromanager/acquisition/python_backend_acquisitions.py @@ -20,7 +20,7 @@ class PythonBackendAcquisition(Acquisition, metaclass=NumpyDocstringInheritanceM def __init__( self, directory: str=None, - name: str=None, + name: str='default_acq_name', image_process_fn: callable=None, event_generation_hook_fn: callable = None, pre_hardware_hook_fn: callable=None, diff --git a/pycromanager/headless.py b/pycromanager/headless.py index 68ccd021..12548e08 100644 --- a/pycromanager/headless.py +++ b/pycromanager/headless.py @@ -118,7 +118,7 @@ def stop_headless(debug=False): def start_headless( mm_app_path: str, config_file: str='', java_loc: str=None, - core_log_path: str='', python_backend=False, + python_backend=False, core_log_path: str='', buffer_size_mb: int=1024, max_memory_mb: int=2000, port: int=_Bridge.DEFAULT_PORT, debug=False): """ @@ -141,14 +141,14 @@ def start_headless( is left to the user. java_loc: str Path to the java version that it should be run with (Java backend only) - core_log_path : str - Path to where core log files should be created python_backend : bool Whether to use the python backend or the Java backend + core_log_path : str + Path to where core log files should be created buffer_size_mb : int Size of circular buffer in MB in MMCore max_memory_mb : int - Maximum amount of memory to be allocated to JVM + Maximum amount of memory to be allocated to JVM (Java backend only port : int Default port to use for ZMQServer (Java backend only) debug : bool @@ -159,6 +159,7 @@ def start_headless( mmc = _create_pymmcore_instance() mmc.set_device_adapter_search_paths([mm_app_path]) mmc.load_system_configuration(config_file) + mmc.set_circular_buffer_memory_footprint(buffer_size_mb) _PYMMCORES.append(mmc) # Store so it doesn't get garbage collected Engine(mmc) else: diff --git a/pycromanager/test/test_notifications.py b/pycromanager/test/test_notifications.py new file mode 100644 index 00000000..deb106f2 --- /dev/null +++ b/pycromanager/test/test_notifications.py @@ -0,0 +1,54 @@ +""" +Tests for notification API and asynchronous acquisition API +""" +from pycromanager import multi_d_acquisition_events, Acquisition, AcqNotification +import time +import numpy as np + +# TODO: add tests for timing of blocking until different parts of the hardware sequence +# def test_async_images_read(launch_mm_headless, setup_data_folder): +# start = time.time() +# events = multi_d_acquisition_events(num_time_points=10, time_interval_s=0.5) +# with Acquisition(directory=setup_data_folder, show_display=False) as acq: +# future = acq.acquire(events) +# +# future.await_execution({'time': 5}, AcqNotification.Hardware.POST_HARDWARE) + + + +def test_async_image_read(launch_mm_headless, setup_data_folder): + events = multi_d_acquisition_events(num_time_points=10, time_interval_s=0.5) + with Acquisition(directory=setup_data_folder, show_display=False) as acq: + future = acq.acquire(events) + image = future.await_image_saved({'time': 5}, return_image=True) + assert np.all(image == acq.get_dataset().read_image(time=5)) + +def test_async_image_read_sequence(launch_mm_headless, setup_data_folder): + events = multi_d_acquisition_events(num_time_points=10, time_interval_s=0) + with Acquisition(directory=setup_data_folder, show_display=False) as acq: + future = acq.acquire(events) + image = future.await_image_saved({'time': 5}, return_image=True) + assert np.all(image == acq.get_dataset().read_image(time=5)) + +def test_async_images_read(launch_mm_headless, setup_data_folder): + events = multi_d_acquisition_events(num_time_points=10, time_interval_s=0.5) + with Acquisition(directory=setup_data_folder, show_display=False) as acq: + future = acq.acquire(events) + images = future.await_image_saved([{'time': 7}, {'time': 8}, {'time': 9}], return_image=True) + assert (len(images) == 3) + + # Make sure the returned images were the correct ones + on_disk = [acq.get_dataset().read_image(time=t) for t in [7, 8, 9]] + assert all([np.all(on_disk[i] == images[i]) for i in range(3)]) + +def test_async_images_read_sequence(launch_mm_headless, setup_data_folder): + events = multi_d_acquisition_events(num_time_points=10, time_interval_s=0) + with Acquisition(directory=setup_data_folder, show_display=False) as acq: + future = acq.acquire(events) + images = future.await_image_saved([{'time': 7}, {'time': 8}, {'time': 9}], return_image=True) + assert (len(images) == 3) + + # Make sure the returned images were the correct ones + on_disk = [acq.get_dataset().read_image(time=t) for t in [7, 8, 9]] + assert all([np.all(on_disk[i] == images[i]) for i in range(3)]) + diff --git a/scripts/adaptive_acquisiton_api.py b/scripts/adaptive_acquisiton_api.py new file mode 100644 index 00000000..4798a980 --- /dev/null +++ b/scripts/adaptive_acquisiton_api.py @@ -0,0 +1,29 @@ +from pycromanager import Acquisition, multi_d_acquisition_events, AcqNotification +import time +import numpy as np + +events = multi_d_acquisition_events(num_time_points=10, time_interval_s=0) + +print_notification_fn = lambda x: print(x.to_json()) + +start = time.time() +with Acquisition(directory='C:\\Users\\henry\\Desktop\\data', name='acq', notification_callback_fn=print_notification_fn, + show_display=False) as acq: + start = time.time() + future = acq.acquire(events) + + future.await_execution({'time': 5}, AcqNotification.Hardware.POST_HARDWARE) + print('time point 5 post hardware') + + image = future.await_image_saved({'time': 5}, return_image=True) + print('time point 5 image saved ', time.time() - start, '\t\tmean image value: ', np.mean(image)) + + images = future.await_image_saved([{'time': 7}, {'time': 8}, {'time': 9}], return_image=True) + assert (len(images) == 3) + for image in images: + print('mean of image in stack: ', np.mean(image)) + +# Make sure the returned images were the correct ones +on_disk = [acq.get_dataset().read_image(time=t) for t in [7, 8, 9]] +assert all([np.all(on_disk[i] == images[i]) for i in range(3)]) + diff --git a/scripts/notification_printer.py b/scripts/notification_printer.py new file mode 100644 index 00000000..5e8a7c0e --- /dev/null +++ b/scripts/notification_printer.py @@ -0,0 +1,16 @@ +from pycromanager import multi_d_acquisition_events, Acquisition, start_headless, stop_headless + +mm_app_path = r"C:\Users\henry\Micro-Manager-nightly" +config = mm_app_path + r"\MMConfig_demo.cfg" +start_headless(mm_app_path, config) + +def print_notification_fn(acq_notification): + print(acq_notification.to_json()) + +with Acquisition(directory=r"C:\Users\henry\Desktop\data", + notification_callback_fn=print_notification_fn, + ) as acq: + acq.acquire(multi_d_acquisition_events(num_time_points=10)) + +stop_headless() +print('done') \ No newline at end of file diff --git a/scripts/python_backend.py b/scripts/python_backend.py new file mode 100644 index 00000000..7b3e50a9 --- /dev/null +++ b/scripts/python_backend.py @@ -0,0 +1,11 @@ +from pycromanager import multi_d_acquisition_events, Core, Acquisition, start_headless, stop_headless + +mm_app_path = r"C:\Users\henry\Micro-Manager-nightly" +config = mm_app_path + r"\MMConfig_demo.cfg" +start_headless(mm_app_path, config, python_backend=True) + +with Acquisition() as acq: + acq.acquire(multi_d_acquisition_events(num_time_points=10)) + + +print('done') \ No newline at end of file From 379115b7c1c8203bcb4be5e868a35af6f890ce38 Mon Sep 17 00:00:00 2001 From: Henry Pinkard <7969470+henrypinkard@users.noreply.github.com> Date: Thu, 7 Sep 2023 13:09:41 -0700 Subject: [PATCH 2/2] fix typo --- docs/source/headless_mode.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/headless_mode.rst b/docs/source/headless_mode.rst index 0d4014b1..3fabd305 100644 --- a/docs/source/headless_mode.rst +++ b/docs/source/headless_mode.rst @@ -24,7 +24,7 @@ Headless mode provides a lightweight environment that can also be run without a core = Core() -The example below shows headless mode in combination with an saved image callback, which calls a user-defined function whenever new data is stored (on disk or in RAM depending on the arguments to ``Acquisitoin``). This setup could be used to replace the pycro-manager viewer with a custom user interface (note the ``show_display=False`` in the acquisition). +The example below shows headless mode in combination with an saved image callback, which calls a user-defined function whenever new data is stored (on disk or in RAM depending on the arguments to ``Acquisition``). This setup could be used to replace the pycro-manager viewer with a custom user interface (note the ``show_display=False`` in the acquisition). .. code-block:: python