From 923d3dec21b49f97cae213d869ada929d6827687 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Thu, 21 Mar 2024 15:02:22 -0400 Subject: [PATCH 1/8] feat: add snap/live support for micromanager Multi Camera utilities --- src/napari_micromanager/_core_link.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/napari_micromanager/_core_link.py b/src/napari_micromanager/_core_link.py index eae4ab8d..ec45396b 100644 --- a/src/napari_micromanager/_core_link.py +++ b/src/napari_micromanager/_core_link.py @@ -5,7 +5,7 @@ import napari import napari.layers -from pymmcore_plus import CMMCorePlus +from pymmcore_plus import CMMCorePlus, Metadata from qtpy.QtCore import QObject, Qt, QTimerEvent from superqt.utils import ensure_main_thread @@ -57,7 +57,9 @@ def timerEvent(self, a0: QTimerEvent | None) -> None: def _image_snapped(self) -> None: # If we are in the middle of an MDA, don't update the preview viewer. if not self._mda_handler._mda_running: - self._update_viewer(self._mmc.getImage()) + # update the viewer with the image from all the cameras + for cam in range(self._mmc.getNumberOfCameraChannels()): + self._update_viewer(*self._mmc.getTaggedImage(cam)) def _start_live(self) -> None: interval = int(self._mmc.getExposure()) @@ -74,21 +76,34 @@ def _restart_live(self, camera: str, exposure: float) -> None: self._mmc.startContinuousSequenceAcquisition() @ensure_main_thread # type: ignore [misc] - def _update_viewer(self, data: np.ndarray | None = None) -> None: + def _update_viewer( + self, data: np.ndarray | None = None, metadata: dict | Metadata | None = None + ) -> None: """Update viewer with the latest image from the circular buffer.""" if data is None: if self._mmc.getRemainingImageCount() == 0: return try: - data = self._mmc.getLastImage() + # get the last image from the circular buffer with metadata + data, metadata = self._mmc.getLastImageAndMD() except (RuntimeError, IndexError): # circular buffer empty return + + if metadata is None: + return + + # get the camera from the metadata + cam = metadata.get("Camera", self._mmc.getCameraDevice()) + layer_name = f"preview ({cam})" + try: - preview_layer = self.viewer.layers["preview"] + preview_layer = self.viewer.layers[layer_name] preview_layer.data = data except KeyError: - preview_layer = self.viewer.add_image(data, name="preview") + preview_layer = self.viewer.add_image( + data, name=layer_name, blending="additive" + ) preview_layer.metadata["mode"] = "preview" From 9542402f51ff8294f845c27a54b7d8e64ccb80ce Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Fri, 22 Mar 2024 09:35:32 -0400 Subject: [PATCH 2/8] feat: add snap/live Multi Camera Utilities support --- src/napari_micromanager/_core_link.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/napari_micromanager/_core_link.py b/src/napari_micromanager/_core_link.py index eae4ab8d..ec45396b 100644 --- a/src/napari_micromanager/_core_link.py +++ b/src/napari_micromanager/_core_link.py @@ -5,7 +5,7 @@ import napari import napari.layers -from pymmcore_plus import CMMCorePlus +from pymmcore_plus import CMMCorePlus, Metadata from qtpy.QtCore import QObject, Qt, QTimerEvent from superqt.utils import ensure_main_thread @@ -57,7 +57,9 @@ def timerEvent(self, a0: QTimerEvent | None) -> None: def _image_snapped(self) -> None: # If we are in the middle of an MDA, don't update the preview viewer. if not self._mda_handler._mda_running: - self._update_viewer(self._mmc.getImage()) + # update the viewer with the image from all the cameras + for cam in range(self._mmc.getNumberOfCameraChannels()): + self._update_viewer(*self._mmc.getTaggedImage(cam)) def _start_live(self) -> None: interval = int(self._mmc.getExposure()) @@ -74,21 +76,34 @@ def _restart_live(self, camera: str, exposure: float) -> None: self._mmc.startContinuousSequenceAcquisition() @ensure_main_thread # type: ignore [misc] - def _update_viewer(self, data: np.ndarray | None = None) -> None: + def _update_viewer( + self, data: np.ndarray | None = None, metadata: dict | Metadata | None = None + ) -> None: """Update viewer with the latest image from the circular buffer.""" if data is None: if self._mmc.getRemainingImageCount() == 0: return try: - data = self._mmc.getLastImage() + # get the last image from the circular buffer with metadata + data, metadata = self._mmc.getLastImageAndMD() except (RuntimeError, IndexError): # circular buffer empty return + + if metadata is None: + return + + # get the camera from the metadata + cam = metadata.get("Camera", self._mmc.getCameraDevice()) + layer_name = f"preview ({cam})" + try: - preview_layer = self.viewer.layers["preview"] + preview_layer = self.viewer.layers[layer_name] preview_layer.data = data except KeyError: - preview_layer = self.viewer.add_image(data, name="preview") + preview_layer = self.viewer.add_image( + data, name=layer_name, blending="additive" + ) preview_layer.metadata["mode"] = "preview" From 89f8e34e374acd6638fd03fe04f4db063a2d9bf8 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Fri, 22 Mar 2024 16:06:39 -0400 Subject: [PATCH 3/8] feat: add multi camera support in handler --- src/napari_micromanager/_mda_handler.py | 27 ++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/napari_micromanager/_mda_handler.py b/src/napari_micromanager/_mda_handler.py index c4fdd142..2587e762 100644 --- a/src/napari_micromanager/_mda_handler.py +++ b/src/napari_micromanager/_mda_handler.py @@ -4,7 +4,7 @@ import tempfile import time from collections import deque -from typing import TYPE_CHECKING, Callable, Generator, cast +from typing import TYPE_CHECKING, Any, Callable, Generator, cast import napari import zarr @@ -60,7 +60,7 @@ def __init__(self, mmcore: CMMCorePlus, viewer: napari.viewer.Viewer) -> None: # mapping of id -> (zarr.Array, temporary directory) for each layer created self._tmp_arrays: dict[str, tuple[zarr.Array, tempfile.TemporaryDirectory]] = {} - self._deck: deque[tuple[np.ndarray, MDAEvent]] = deque() + self._deck: deque[tuple[np.ndarray, MDAEvent, dict[str, Any]]] = deque() # Add all core connections to this list. This makes it easy to disconnect # from core when this widget is closed. @@ -92,6 +92,15 @@ def _on_mda_started(self, sequence: MDASequence) -> None: # (based on the sequence mode, and whether we're splitting C/P, etc.) axis_labels, layers_to_create = _determine_sequence_layers(sequence) + # TODO: maybe move it _determine_sequence_layers + cameras = self._mmc.getCameraChannelNames() + if len(cameras) > 1: + layers_to_create = [ + (f"{_id}_{camera}", *items) # type: ignore + for camera in cameras + for _id, *items in layers_to_create + ] + yx_shape = [self._mmc.getImageHeight(), self._mmc.getImageWidth()] # now create a zarr array in a temporary directory for each layer @@ -143,16 +152,24 @@ def _watch_mda( else: time.sleep(0.1) - def _on_mda_frame(self, image: np.ndarray, event: MDAEvent) -> None: + def _on_mda_frame( + self, image: np.ndarray, event: MDAEvent, meta: dict[str, Any] + ) -> None: """Called on the `frameReady` event from the core.""" - self._deck.append((image, event)) + self._deck.append((image, event, meta)) def _process_frame( - self, image: np.ndarray, event: MDAEvent + self, image: np.ndarray, event: MDAEvent, meta: dict[str, Any] ) -> tuple[str | None, tuple[int, ...] | None]: # get info about the layer we need to update _id, im_idx, layer_name = _id_idx_layer(event) + # TODO: maybe move it _id_idx_layer + # "Camera" is only present in case there are multiple cameras + if camera := meta.get("Camera"): + _id = f"{_id}_{camera}" + layer_name = f"{layer_name}_{camera}" + # update the zarr array backing the layer self._tmp_arrays[_id][0][im_idx] = image From b7277351420584b8f189d58467136a18a87d9ad5 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 2 Apr 2024 11:26:37 -0400 Subject: [PATCH 4/8] fix: add comment --- src/napari_micromanager/_core_link.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/napari_micromanager/_core_link.py b/src/napari_micromanager/_core_link.py index ec45396b..525eb590 100644 --- a/src/napari_micromanager/_core_link.py +++ b/src/napari_micromanager/_core_link.py @@ -59,6 +59,7 @@ def _image_snapped(self) -> None: if not self._mda_handler._mda_running: # update the viewer with the image from all the cameras for cam in range(self._mmc.getNumberOfCameraChannels()): + # using tagged image to then get the camera name from the metadata self._update_viewer(*self._mmc.getTaggedImage(cam)) def _start_live(self) -> None: From e027e874c8a435f78f7bb430cfb5d7e2fbcbd0bb Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 24 Apr 2024 15:19:11 -0400 Subject: [PATCH 5/8] test: update --- tests/test_layer_scale.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_layer_scale.py b/tests/test_layer_scale.py index e3b3547f..bec82c78 100644 --- a/tests/test_layer_scale.py +++ b/tests/test_layer_scale.py @@ -10,6 +10,8 @@ from pymmcore_plus import CMMCorePlus from useq import MDASequence +import warnings + @pytest.mark.parametrize("axis_order", ["tpcz", "tpzc"]) def test_layer_scale( @@ -62,11 +64,15 @@ def test_layer_scale( def test_preview_scale(core: CMMCorePlus, main_window: MainWindow): + warnings.filterwarnings("ignore", category=DeprecationWarning) img = core.snap() main_window._core_link._update_viewer(img) pix_size = core.getPixelSizeUm() - assert tuple(main_window.viewer.layers["preview"].scale) == (pix_size, pix_size) + assert tuple(main_window.viewer.layers["preview (Camera)"].scale) == ( + pix_size, + pix_size, + ) # now pretend that the user never provided a pixel size config # we need to not crash in this case From 3f4cc8640453e9d6b1fa05741e8be6b033db69ef Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 24 Apr 2024 16:07:50 -0400 Subject: [PATCH 6/8] fix: update camela layer logic --- src/napari_micromanager/_mda_handler.py | 41 ++++++++++++------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/src/napari_micromanager/_mda_handler.py b/src/napari_micromanager/_mda_handler.py index 2587e762..0bf32e87 100644 --- a/src/napari_micromanager/_mda_handler.py +++ b/src/napari_micromanager/_mda_handler.py @@ -90,16 +90,7 @@ def _on_mda_started(self, sequence: MDASequence) -> None: # determine the new layers that need to be created for this experiment # (based on the sequence mode, and whether we're splitting C/P, etc.) - axis_labels, layers_to_create = _determine_sequence_layers(sequence) - - # TODO: maybe move it _determine_sequence_layers - cameras = self._mmc.getCameraChannelNames() - if len(cameras) > 1: - layers_to_create = [ - (f"{_id}_{camera}", *items) # type: ignore - for camera in cameras - for _id, *items in layers_to_create - ] + axis_labels, layers_to_create = _determine_sequence_layers(sequence, self._mmc) yx_shape = [self._mmc.getImageHeight(), self._mmc.getImageWidth()] @@ -162,13 +153,7 @@ def _process_frame( self, image: np.ndarray, event: MDAEvent, meta: dict[str, Any] ) -> tuple[str | None, tuple[int, ...] | None]: # get info about the layer we need to update - _id, im_idx, layer_name = _id_idx_layer(event) - - # TODO: maybe move it _id_idx_layer - # "Camera" is only present in case there are multiple cameras - if camera := meta.get("Camera"): - _id = f"{_id}_{camera}" - layer_name = f"{layer_name}_{camera}" + _id, im_idx, layer_name = _id_idx_layer(event, meta) # update the zarr array backing the layer self._tmp_arrays[_id][0][im_idx] = image @@ -259,7 +244,7 @@ def _has_sub_sequences(sequence: MDASequence) -> bool: def _determine_sequence_layers( - sequence: MDASequence, + sequence: MDASequence, mmcore: CMMCorePlus ) -> tuple[list[str], list[tuple[str, list[int], LayerMeta]]]: # sourcery skip: extract-duplicate-method """Return (axis_labels, (id, shape, and metadata)) for each layer to add for seq. @@ -325,10 +310,19 @@ def _determine_sequence_layers( axis_labels += ["y", "x"] + # add camera name to id if more than one camera + cameras = mmcore.getCameraChannelNames() + if len(cameras) > 1: + _layer_info = [ + (f"{_id}_{camera}", *items) # type: ignore + for camera in cameras + for _id, *items in _layer_info + ] + return axis_labels, _layer_info -def _id_idx_layer(event: MDAEvent) -> tuple[str, tuple[int, ...], str]: +def _id_idx_layer(event: MDAEvent, meta: dict[str, Any]) -> tuple[str, tuple[int, ...], str]: """Get the tmp_path id, index, and layer name for a given event. Parameters @@ -347,14 +341,14 @@ def _id_idx_layer(event: MDAEvent) -> tuple[str, tuple[int, ...], str]: - `layer_name` is the name of the corresponding layer in the viewer. """ seq = cast("MDASequence", event.sequence) - meta = cast(dict, seq.metadata.get(NMM_METADATA_KEY, {})) + nmm_meta = cast(dict, seq.metadata.get(NMM_METADATA_KEY, {})) axis_order = list(get_full_sequence_axes(seq)) ch_id = "" # get filename from MDASequence metadata prefix = _get_file_name_from_metadata(seq) - if meta.get("split_channels", False) and event.channel: + if nmm_meta.get("split_channels", False) and event.channel: ch_id = f"{event.channel.config}_{event.index['c']:03d}_" axis_order.remove("c") @@ -373,4 +367,9 @@ def _id_idx_layer(event: MDAEvent) -> tuple[str, tuple[int, ...], str]: # the name of this layer in the napari viewer layer_name = f"{prefix}_{ch_id}{seq.uid}" + # "Camera" is present in meta only in case there are multiple cameras + if camera := meta.get("Camera"): + _id = f"{_id}_{camera}" + layer_name = f"{layer_name}_{camera}" + return _id, im_idx, layer_name From 707749e3048f2410e38e44fe18e06938d9b54112 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 24 Apr 2024 20:10:30 +0000 Subject: [PATCH 7/8] style: [pre-commit.ci] auto fixes [...] --- src/napari_micromanager/_mda_handler.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/napari_micromanager/_mda_handler.py b/src/napari_micromanager/_mda_handler.py index 0bf32e87..bae21135 100644 --- a/src/napari_micromanager/_mda_handler.py +++ b/src/napari_micromanager/_mda_handler.py @@ -322,7 +322,9 @@ def _determine_sequence_layers( return axis_labels, _layer_info -def _id_idx_layer(event: MDAEvent, meta: dict[str, Any]) -> tuple[str, tuple[int, ...], str]: +def _id_idx_layer( + event: MDAEvent, meta: dict[str, Any] +) -> tuple[str, tuple[int, ...], str]: """Get the tmp_path id, index, and layer name for a given event. Parameters From 1c49dd946d95ce67f3fcb959c36af0a19741283a Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 24 Apr 2024 16:24:14 -0400 Subject: [PATCH 8/8] fix: docstring --- src/napari_micromanager/_mda_handler.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/napari_micromanager/_mda_handler.py b/src/napari_micromanager/_mda_handler.py index bae21135..ac4b7f4e 100644 --- a/src/napari_micromanager/_mda_handler.py +++ b/src/napari_micromanager/_mda_handler.py @@ -261,6 +261,8 @@ def _determine_sequence_layers( The YX shape of a single image in the sequence. (this argument might not need to be passed here, perhaps could be handled be the caller of this function) + mmcore : CMMCorePlus + The Micro-Manager core instance. Returns ------- @@ -331,7 +333,8 @@ def _id_idx_layer( ---------- event : MDAEvent An event for which to retrieve the id, index, and layer name. - + meta : dict[str, Any] + Metadata for the sequence. Returns -------