diff --git a/src/napari_micromanager/_gui_objects/_mda_widget.py b/src/napari_micromanager/_gui_objects/_mda_widget.py index 60b12bbd..e21aa8b1 100644 --- a/src/napari_micromanager/_gui_objects/_mda_widget.py +++ b/src/napari_micromanager/_gui_objects/_mda_widget.py @@ -43,13 +43,10 @@ def value(self) -> MDASequence: sequence = super().value() widget_meta = sequence.metadata.get(MMCORE_WIDGETS_META, {}) split = self.checkBox_split_channels.isChecked() and len(sequence.channels) > 1 - sequence.metadata[SEQUENCE_META_KEY] = SequenceMeta( mode="mda", split_channels=bool(split), - save_dir=widget_meta.get("save_dir", ""), file_name=widget_meta.get("save_name", ""), - should_save=bool("save_dir" in widget_meta), ) return sequence # type: ignore[no-any-return] @@ -60,12 +57,6 @@ def setValue(self, value: MDASequence) -> None: nmm_meta = SequenceMeta(**nmm_meta) if not isinstance(nmm_meta, SequenceMeta): # pragma: no cover raise TypeError(f"Expected {SequenceMeta}, got {type(nmm_meta)}") - - # update pymmcore_widgets metadata if SequenceMeta are provided - widgets_meta = value.metadata.setdefault(MMCORE_WIDGETS_META, {}) - widgets_meta.setdefault("save_dir", nmm_meta.save_dir) - widgets_meta.setdefault("save_name", nmm_meta.file_name) - # set split_channels checkbox self.checkBox_split_channels.setChecked(bool(nmm_meta.split_channels)) super().setValue(value) diff --git a/src/napari_micromanager/_mda_handler.py b/src/napari_micromanager/_mda_handler.py index 1269c0bb..fd340f64 100644 --- a/src/napari_micromanager/_mda_handler.py +++ b/src/napari_micromanager/_mda_handler.py @@ -11,7 +11,6 @@ from superqt.utils import create_worker, ensure_main_thread from ._mda_meta import SEQUENCE_META_KEY, SequenceMeta -from ._saving import save_sequence if TYPE_CHECKING: from uuid import UUID @@ -53,6 +52,9 @@ class LayerMeta(TypedDict): translate: NotRequired[bool] +EXP = "Exp" + + # NOTE: import from pymmcore-plus when new version will be released: # from pymmcore_plus.mda.handlers._util import get_full_sequence_axes def get_full_sequence_axes(sequence: MDASequence) -> tuple[str, ...]: @@ -144,7 +146,7 @@ def _on_mda_started(self, sequence: MDASequence) -> None: dtype=dtype, chunks=tuple([1] * len(shape) + yx_shape), # VERY IMPORTANT FOR SPEED! ) - fname = meta.file_name if meta.should_save else "Exp" + fname = meta.file_name or EXP self._create_empty_image_layer(z, f"{fname}_{id_}", sequence, **kwargs) # store the zarr array and temporary directory for later cleanup @@ -162,8 +164,6 @@ def _on_mda_started(self, sequence: MDASequence) -> None: self._watch_mda, _start_thread=True, _connect={"yielded": self._update_viewer_dims}, - # NOTE: once we have a proper writer, we can add here: - # "finished": self._process_remaining_frames ) # Set the viewer slider on the first layer frame @@ -237,28 +237,14 @@ def _reset_viewer_dims(self) -> None: def _on_mda_finished(self, sequence: MDASequence) -> None: self._mda_running = False + self._process_remaining_frames() - # NOTE: this will be REMOVED when using proper WRITER (e.g. - # https://github.com/pymmcore-plus/pymmcore-MDA-writers or - # https://github.com/fdrgsp/pymmcore-MDA-writers/tree/update_writer). See the - # comment in _process_remaining_frames for more details. - self._process_remaining_frames(sequence) - - def _process_remaining_frames(self, sequence: MDASequence) -> None: + def _process_remaining_frames(self) -> None: """Process any remaining frames after the MDA has finished.""" - # NOTE: when switching to a proper wtiter to save files, this method will not - # have the sequence argument, it will not be called by `_on_mda_finished` but we - # can link it to the self._io_t.finished signal ("finished": self._process_ - # remaining_frames) and the saving code below will be removed. self._reset_viewer_dims() while self._deck: self._process_frame(*self._deck.pop()) - # to remove when using proper writer - if (meta := sequence.metadata.get(SEQUENCE_META_KEY)) is not None: - sequence = cast("ActiveMDASequence", sequence) - save_sequence(sequence, self.viewer.layers, meta) - def _create_empty_image_layer( self, arr: zarr.Array, @@ -344,7 +330,7 @@ def _determine_sequence_layers( `[('3670fc63-c570-4920-949f-16601143f2e3', [4, 2, 4], {})]` """ # if we got to this point, sequence.metadata[SEQUENCE_META_KEY] should exist - meta = sequence.metadata["napari_mm_sequence_meta"] + meta = sequence.metadata[SEQUENCE_META_KEY] # type: ignore # these are all the layers we're going to create # each item is a tuple of (id, shape, layer_metadata) @@ -407,7 +393,7 @@ def _id_idx_layer(event: ActiveMDAEvent) -> tuple[str, tuple[int, ...], str]: axis_order = list(get_full_sequence_axes(event.sequence)) suffix = "" - prefix = meta.file_name if meta.should_save else "Exp" + prefix = meta.file_name or EXP if meta.split_channels and event.channel: suffix = f"_{event.channel.config}_{event.index['c']:03d}" diff --git a/src/napari_micromanager/_mda_meta.py b/src/napari_micromanager/_mda_meta.py index 0e9f9849..29474106 100644 --- a/src/napari_micromanager/_mda_meta.py +++ b/src/napari_micromanager/_mda_meta.py @@ -21,9 +21,6 @@ class SequenceMeta: mode: str = "" split_channels: bool = False file_name: str = "" - save_dir: str = "" - should_save: bool = False # to remove when using pymmcore-plus writers - save_pos: bool = False # to remove when using pymmcore-plus writers def replace(self, **kwargs: Any) -> SequenceMeta: """Return a new SequenceMeta with the given kwargs replaced.""" diff --git a/src/napari_micromanager/_saving.py b/src/napari_micromanager/_saving.py deleted file mode 100644 index 6768fce4..00000000 --- a/src/napari_micromanager/_saving.py +++ /dev/null @@ -1,102 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING - -import numpy as np -import tifffile - -from ._util import ensure_unique - -if TYPE_CHECKING: - from napari.components import LayerList - from useq import MDASequence - - from napari_micromanager._mda_meta import SequenceMeta - - -def _imsave(file: Path, data: np.ndarray, dtype: str = "uint16") -> None: - tifffile.imwrite( - str(file), data.astype(dtype), imagej=data.ndim <= 5, photometric="MINISBLACK" - ) - - -def save_sequence(sequence: MDASequence, layers: LayerList, meta: SequenceMeta) -> None: - """Save `layers` associated with an MDA `sequence` to disk. - - Parameters - ---------- - sequence : MDASequence - An MDA sequence being run. - layers : LayerList - A list of layers acquired during the MDA sequence. - meta : SequenceMeta - Internal metadata associated with the sequence. - """ - if not meta: - return - if not meta.should_save: - return - if meta.mode in ("mda", ""): - return _save_mda_sequence(sequence, layers, meta) - raise NotImplementedError(f"cannot save experiment with mode: {meta.mode}") - - -def _save_mda_sequence( - sequence: MDASequence, layers: LayerList, meta: SequenceMeta -) -> None: - path = Path(meta.save_dir) - file_name = meta.file_name - folder_name = ensure_unique(path / file_name, extension="", ndigits=3) - - mda_layers = [i for i in layers if i.metadata.get("uid") == sequence.uid] - # if split_channels, then create a new layer for each channel - if meta.split_channels: - folder_name.mkdir(parents=True, exist_ok=True) - - if meta.save_pos: - # save each position/channels in a separate file. - _save_pos_separately(sequence, folder_name, folder_name.stem, mda_layers) - else: - # save each channel layer. - for lay in mda_layers: - fname = f'{folder_name.stem}_{lay.metadata.get("ch_id")}.tif' - # TODO: smarter behavior w.r.t type of lay.data - # currently this will force the data into memory which may cause a crash - # long term solution is to remove this code and rely on an - # mda-writer either in pymmcore-plus or elsewhere. - _imsave(folder_name / fname, np.squeeze(lay.data)) - return - - # not splitting channels - active_layer = mda_layers[0] - - if meta.save_pos: - # save each position in a separate file - folder_name.mkdir(parents=True, exist_ok=True) - for p in range(len(sequence.stage_positions)): - dest = folder_name / f"{folder_name.stem}_p{p:03d}.tif" - ax = 1 if sequence.sizes.get("t", 0) > 0 else 0 - pos_data = np.take(active_layer.data, 0, axis=ax) - _imsave(dest, np.squeeze(pos_data)) - - else: - # not saving each position in a separate file - save_path = ensure_unique(path / file_name, extension=".tif", ndigits=3) - # TODO: see above TODO - _imsave(save_path, np.squeeze(active_layer.data)) - - -def _save_pos_separately( - sequence: MDASequence, folder_name: Path, fname: str, layers: LayerList -) -> None: - for p in range(len(sequence.stage_positions)): - folder_path = folder_name / f"{fname}_Pos{p:03d}" - folder_path.mkdir(parents=True, exist_ok=True) - - for i in layers: - if "ch_id" not in i.metadata or i.metadata.get("uid") != sequence.uid: - continue - filename = f"{fname}_{i.metadata['ch_id']}_p{p:03}" - ax = sequence.axis_order.index("p") if sequence.sizes.get("t", 0) > 0 else 0 - _imsave(folder_path / f"{filename}.tif", np.take(i.data, p, axis=ax)) diff --git a/tests/conftest.py b/tests/conftest.py index 484b8e69..a932bbb0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,7 +44,7 @@ def main_window(core: CMMCorePlus, make_napari_viewer): @pytest.fixture(params=MDAS, ids=MDA_IDS) def mda_sequence(request: pytest.FixtureRequest) -> useq.MDASequence: - seq_meta = SequenceMeta(mode="mda", file_name="test_mda", should_save=True) + seq_meta = SequenceMeta(mode="mda", file_name="test_mda") return useq.MDASequence(**request.param, metadata={SEQUENCE_META_KEY: seq_meta}) diff --git a/tests/test_layer_scale.py b/tests/test_layer_scale.py index 65dbf234..758c4360 100644 --- a/tests/test_layer_scale.py +++ b/tests/test_layer_scale.py @@ -27,10 +27,14 @@ def test_layer_scale( sequence = mda_sequence_splits.replace( axis_order=axis_order, - metadata={SEQUENCE_META_KEY: SequenceMeta(should_save=False)}, ) z_step = sequence.z_plan and sequence.z_plan.step + # the MDASequence 'replace' switchesp the metadata to a dict, here we switch it back + meta = sequence.metadata[SEQUENCE_META_KEY] + meta = SequenceMeta(**meta) + sequence = sequence.replace(metadata={SEQUENCE_META_KEY: meta}) + # create zarr layer handler._on_mda_started(sequence) diff --git a/tests/test_multid_widget.py b/tests/test_multid_widget.py index 00f10bb8..fbe40057 100644 --- a/tests/test_multid_widget.py +++ b/tests/test_multid_widget.py @@ -2,13 +2,10 @@ from typing import TYPE_CHECKING -from napari_micromanager._gui_objects._mda_widget import MultiDWidget from napari_micromanager._mda_meta import SEQUENCE_META_KEY, SequenceMeta -from pymmcore_plus.mda import MDAEngine from useq import MDASequence if TYPE_CHECKING: - from pathlib import Path from napari_micromanager.main_window import MainWindow from pytestqt.qtbot import QtBot @@ -29,63 +26,6 @@ def test_main_window_mda(main_window: MainWindow): assert main_window.viewer.layers[-1].data.nchunks_initialized == 32 -def test_saving_mda( - qtbot: QtBot, - main_window: MainWindow, - mda_sequence_splits: MDASequence, - tmp_path: Path, -) -> None: - mda = mda_sequence_splits - main_window._show_dock_widget("MDA") - mda_widget = main_window._dock_widgets["MDA"].widget() - assert isinstance(mda_widget, MultiDWidget) - - # FIXME: - # we have a bit of a battle here now for file-saving metadata between - # pymmcore_widgets and napari_micromanager's SequenceMetadata - # should standardize, possibly by adding to useq-schema - # this test uses the pymmcore-widgets metadata for now - widget_meta = mda.metadata.setdefault("pymmcore_widgets", {}) - widget_meta["save_dir"] = str(tmp_path) - widget_meta["should_save"] = True - - mda_widget.setValue(mda) - assert mda_widget.save_info.isChecked() - meta = mda_widget.value().metadata[SEQUENCE_META_KEY] - assert meta.save_dir == str(tmp_path) - # using `metadata=mda.metadata` here to keep the SequenceMeta object - # rather than a serialized dict... this is a hack to get around a broader issue - # with the .replace method - mda = mda.replace(axis_order=mda_widget.value().axis_order, metadata=mda.metadata) - mmc = main_window._mmc - - # re-register twice to fully exercise the logic of the update - # functions - the initial connections are made in init - # then after that they are fully handled by the _update_mda_engine - # callbacks - mmc.register_mda_engine(MDAEngine(mmc)) - mmc.register_mda_engine(MDAEngine(mmc)) - - # make the images non-square - mmc.setProperty("Camera", "OnCameraCCDYSize", 500) - with qtbot.waitSignal(mmc.mda.events.sequenceFinished, timeout=8000): - mda_widget.control_btns.run_btn.click() - - data_shape = [x for x in main_window.viewer.layers[-1].data.shape if x > 1] - expected_shape = [x for x in (*mda.shape, 500, 512) if x > 1] - - multiC = len(mda.channels) > 1 - splitC = mda.metadata[SEQUENCE_META_KEY].split_channels - if multiC and splitC: - expected_shape.pop(mda.used_axes.find("c")) - nfiles = len(list((tmp_path / f"{meta.file_name}_000").iterdir())) - assert nfiles == 2 if multiC else 1 - # splitC with one channel is the same as not split - else: - assert [p.name for p in tmp_path.iterdir()] == [f"{meta.file_name}_000.tif"] - assert data_shape == expected_shape - - def test_script_initiated_mda(main_window: MainWindow, qtbot: QtBot): # we should show the mda even if it came from outside mmc = main_window._mmc