Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: remove save logic and use pymmcore-plus writer from MDAWidget #315

Merged
merged 25 commits into from
Mar 6, 2024
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
671e40e
feat: remove save logic and use pymmcore-plus writer from MDAWidget
fdrgsp Feb 18, 2024
dc64c34
fix: parser
fdrgsp Feb 19, 2024
e1101d0
fix: get filename from MDAWidget sequence metadata
fdrgsp Feb 19, 2024
2feb76f
fix: pin new pymmcore_plus version
fdrgsp Feb 19, 2024
49b337e
fix: update
fdrgsp Mar 2, 2024
a1dc243
Merge branch 'main' into use_pymmcore_writers
fdrgsp Mar 2, 2024
f6cf121
fix: comment
fdrgsp Mar 3, 2024
21f2c49
fix: update LayerMeta
fdrgsp Mar 3, 2024
da209b0
fix: import PYMMCW_METADATA_KEY from useq
fdrgsp Mar 3, 2024
ca1f8e2
Merge branch 'main' into use_pymmcore_writers
tlambert03 Mar 4, 2024
b3beee7
style: [pre-commit.ci] auto fixes [...]
pre-commit-ci[bot] Mar 4, 2024
b4dabf1
fix: remove SequenceMeta
fdrgsp Mar 4, 2024
94c2865
Merge branch 'use_pymmcore_writers' of https://github.com/fdrgsp/napa…
fdrgsp Mar 4, 2024
39a73c3
fix: update layer name
fdrgsp Mar 4, 2024
f91f380
test: fix tests
fdrgsp Mar 4, 2024
1e9b47a
fix: PYMMCW_METADATA_KEY
fdrgsp Mar 4, 2024
d578dcf
fix: rename variable
fdrgsp Mar 4, 2024
5e5c313
fix: get_full_sequence_axes from pymmcore_plus
fdrgsp Mar 4, 2024
03c6cc3
tweaks, and add back test
tlambert03 Mar 5, 2024
9c3d1e3
final tests
tlambert03 Mar 5, 2024
74c10a6
bump widgets as well
tlambert03 Mar 5, 2024
b99b796
bump dep
tlambert03 Mar 5, 2024
a231137
Merge branch 'main' into use_pymmcore_writers
tlambert03 Mar 5, 2024
6434347
Merge branch 'main' into use_pymmcore_writers
tlambert03 Mar 5, 2024
ec2a4e4
Merge branch 'main' into use_pymmcore_writers
tlambert03 Mar 6, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ repos:
files: "^src/"
additional_dependencies:
- useq-schema >=0.4.7
- pymmcore_plus >=0.6.7
- pymmcore_plus >=0.9.0
# # unfortunately... bringing these in brings in qtpy
# # which has too many attr-defined errors ...
# # and selectively ignoring them isn't working
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ dynamic = ["version"]
dependencies = [
"fonticon-materialdesignicons6",
"napari >=0.4.13",
"pymmcore-plus >=0.8.0",
"pymmcore-plus >=0.9.0",
"pymmcore-widgets >=0.5.3",
"superqt >=0.5.1",
"tifffile",
Expand Down
32 changes: 8 additions & 24 deletions src/napari_micromanager/_gui_objects/_mda_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@
QWidget,
)

from napari_micromanager._mda_meta import SEQUENCE_META_KEY, SequenceMeta

if TYPE_CHECKING:
from pymmcore_plus import CMMCorePlus
from useq import MDASequence


MMCORE_WIDGETS_META = "pymmcore_widgets"
NAPARI_MM_META = "napari_micromanager"


class MultiDWidget(MDAWidget):
Expand All @@ -41,31 +41,15 @@
"""Return the current value of the widget."""
# Overriding the value method to add the metadata necessary for the handler.
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),
)
sequence.metadata[NAPARI_MM_META] = {"split_channels": split}

Check warning on line 45 in src/napari_micromanager/_gui_objects/_mda_widget.py

View check run for this annotation

Codecov / codecov/patch

src/napari_micromanager/_gui_objects/_mda_widget.py#L45

Added line #L45 was not covered by tests
return sequence # type: ignore[no-any-return]

def setValue(self, value: MDASequence) -> None:
"""Set the current value of the widget."""
if nmm_meta := value.metadata.get(SEQUENCE_META_KEY):
if isinstance(nmm_meta, dict):
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))
# set split_channels checkbox
if nmm_meta := value.metadata.get(NAPARI_MM_META):
self.checkBox_split_channels.setChecked(

Check warning on line 52 in src/napari_micromanager/_gui_objects/_mda_widget.py

View check run for this annotation

Codecov / codecov/patch

src/napari_micromanager/_gui_objects/_mda_widget.py#L51-L52

Added lines #L51 - L52 were not covered by tests
nmm_meta.get("split_channels", False)
)
super().setValue(value)
127 changes: 42 additions & 85 deletions src/napari_micromanager/_mda_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@

import napari
import zarr
from pymmcore_plus.mda.handlers._util import get_full_sequence_axes
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

never import private stuff from other packages, even if it's your package. I know it's annoying. but it just always comes back to bite you. I'll update this one

from superqt.utils import create_worker, ensure_main_thread

from ._mda_meta import SEQUENCE_META_KEY, SequenceMeta
from ._saving import save_sequence
NAPARI_MM_META = "napari_micromanager"
PYMMCW_METADATA_KEY = "pymmcore_widgets"

if TYPE_CHECKING:
from uuid import UUID
Expand All @@ -24,27 +25,17 @@
from typing_extensions import NotRequired, TypedDict
from useq import MDAEvent, MDASequence

class SequenceMetaDict(TypedDict):
"""Dict containing the SequenceMeta object that we add when starting MDAs."""

napari_mm_sequence_meta: SequenceMeta

class ActiveMDASequence(MDASequence):
"""MDASequence that whose metadata dict contains our special SequenceMeta."""

metadata: SequenceMetaDict # type: ignore [assignment]

class ActiveMDAEvent(MDAEvent):
"""Event that has been assigned a sequence."""

sequence: ActiveMDASequence

# TODO: the keys are accurate, but currently this is at the top level layer.metadata
# we should nest it under a napari_micromanager key
class LayerMeta(TypedDict):
"""Metadata that we add to layer.metadata."""

mode: str
useq_sequence: MDASequence
uid: UUID
grid: NotRequired[str]
Expand All @@ -53,22 +44,14 @@ class LayerMeta(TypedDict):
translate: NotRequired[bool]


# 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, ...]:
"""Get the combined axes from sequence and sub-sequences."""
# axes main sequence
main_seq_axes = list(sequence.used_axes)
if not sequence.stage_positions:
return tuple(main_seq_axes)
# axes from sub sequences
sub_seq_axes: list = []
for p in sequence.stage_positions:
if p.sequence is not None:
sub_seq_axes.extend(
[ax for ax in p.sequence.used_axes if ax not in main_seq_axes]
)
return tuple(main_seq_axes + sub_seq_axes)
DEFAULT_NAME = "Exp"


def _get_file_name_from_metadata(sequence: MDASequence) -> str:
"""Get the file name from the MDASequence metadata."""
meta = sequence.metadata.get(PYMMCW_METADATA_KEY)
fname = "" if meta is None else meta.get("save_name", "")
return fname or DEFAULT_NAME


class _NapariMDAHandler:
Expand Down Expand Up @@ -116,14 +99,7 @@ def _cleanup(self) -> None:
@ensure_main_thread # type: ignore [misc]
def _on_mda_started(self, sequence: MDASequence) -> None:
"""Create temp folder and block gui when mda starts."""
meta: SequenceMeta | None = sequence.metadata.get(SEQUENCE_META_KEY)

if meta is None:
# this is not an MDA we started
# so just use the default napari_mm metadata
meta = sequence.metadata[SEQUENCE_META_KEY] = SequenceMeta()
sequence = cast("ActiveMDASequence", sequence)

# pause acquisition until zarr layer(s) are added
self._mmc.mda.toggle_pause()

Expand All @@ -144,7 +120,8 @@ 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"
# get filename from MDASequence metadata
fname = _get_file_name_from_metadata(sequence)
self._create_empty_image_layer(z, f"{fname}_{id_}", sequence, **kwargs)

# store the zarr array and temporary directory for later cleanup
Expand All @@ -162,8 +139,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
Expand All @@ -190,12 +165,6 @@ def _on_mda_frame(self, image: np.ndarray, event: MDAEvent) -> None:
def _process_frame(
self, image: np.ndarray, event: MDAEvent
) -> tuple[str | None, tuple[int, ...] | None]:
seq_meta = getattr(event.sequence, "metadata", None)

if not (seq_meta and seq_meta.get(SEQUENCE_META_KEY)):
# this is not an MDA we started
return None, None

event = cast("ActiveMDAEvent", event)

# get info about the layer we need to update
Expand All @@ -209,7 +178,7 @@ def _process_frame(
self._largest_idx = im_idx
return layer_name, im_idx

return None, None
return layer_name, None

@ensure_main_thread # type: ignore [misc]
def _update_viewer_dims(
Expand All @@ -218,13 +187,13 @@ def _update_viewer_dims(
"""Update the viewer dims to match the current image."""
layer_name, im_idx = args

if layer_name is None or im_idx is None:
return

layer: Image = self.viewer.layers[layer_name]
if not layer.visible:
layer.visible = True

if im_idx is None:
return

cs = list(self.viewer.dims.current_step)
for a, v in enumerate(im_idx):
cs[a] = v
Expand All @@ -237,28 +206,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,
Expand All @@ -280,31 +235,31 @@ def _create_empty_image_layer(
Extra kwargs will be added to `layer.metadata`.
"""
# we won't have reached this point if meta is None
meta = cast("SequenceMeta", sequence.metadata.get(SEQUENCE_META_KEY))
meta = sequence.metadata.get(NAPARI_MM_META, {})

# add Z to layer scale
if (pix_size := self._mmc.getPixelSizeUm()) != 0:
scale = [1.0] * (arr.ndim - 2) + [pix_size] * 2
if (index := sequence.used_axes.find("z")) > -1:
if meta.split_channels and sequence.used_axes.find("c") < index:
if meta.get("split_channels") and sequence.used_axes.find("c") < index:
index -= 1
scale[index] = getattr(sequence.z_plan, "step", 1)
else:
# return to default
scale = [1.0, 1.0]

layer_meta: LayerMeta = {
"useq_sequence": sequence,
"uid": sequence.uid,
}

return self.viewer.add_image(
arr,
name=name,
blending="additive",
blending="opaque",
visible=False,
scale=scale,
metadata={
"mode": meta.mode,
"useq_sequence": sequence,
"uid": sequence.uid,
**kwargs,
},
metadata={NAPARI_MM_META: layer_meta, **kwargs},
)


Expand Down Expand Up @@ -343,8 +298,8 @@ def _determine_sequence_layers(
layer, and `layer_meta` is metadata to add to `layer.metadata`. e.g.:
`[('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 = cast(dict, sequence.metadata.get(NAPARI_MM_META, {}))
split = meta.get("split_channels", False)

# these are all the layers we're going to create
# each item is a tuple of (id, shape, layer_metadata)
Expand All @@ -367,13 +322,13 @@ def _determine_sequence_layers(
layer_shape[index] = max(layer_shape[index], pos_shape)

# in split channels mode, we need to create a layer for each channel
if meta.split_channels:
if split:
c_idx = axis_labels.index("c")
axis_labels.pop(c_idx)
layer_shape.pop(c_idx)
for i, ch in enumerate(sequence.channels):
channel_id = f"{ch.config}_{i:03d}"
id_ = f"{sequence.uid}_{channel_id}"
id_ = f"{channel_id}_{sequence.uid}"
_layer_info.append((id_, layer_shape, {"ch_id": channel_id}))

else:
Expand Down Expand Up @@ -402,30 +357,32 @@ def _id_idx_layer(event: ActiveMDAEvent) -> tuple[str, tuple[int, ...], str]:
should be saved.
- `layer_name` is the name of the corresponding layer in the viewer.
"""
meta = cast("SequenceMeta", event.sequence.metadata.get(SEQUENCE_META_KEY))
meta = cast(dict, event.sequence.metadata.get(NAPARI_MM_META, {}))
split = meta.get("split_channels", False)

axis_order = list(get_full_sequence_axes(event.sequence))

suffix = ""
prefix = meta.file_name if meta.should_save else "Exp"
ch_id = ""
# get filename from MDASequence metadata
prefix = _get_file_name_from_metadata(event.sequence)

if meta.split_channels and event.channel:
suffix = f"_{event.channel.config}_{event.index['c']:03d}"
if split and event.channel:
ch_id = f"{event.channel.config}_{event.index['c']:03d}_"
axis_order.remove("c")

_id = f"{event.sequence.uid}{suffix}"
_id = f"{ch_id}{event.sequence.uid}"

# the index of this event in the full zarr array
im_idx: tuple[int, ...] = ()
for k in axis_order:
try:
im_idx += (event.index[k],)
# if axis not in event.index
# e.g. if we have both a position sequence grid and a single position
# e.g. if we have both a position with and one without a sub-sequence grid
except KeyError:
im_idx += (0,)

# the name of this layer in the napari viewer
layer_name = f"{prefix}_{event.sequence.uid}{suffix}"
layer_name = f"{prefix}_{ch_id}{event.sequence.uid}"

return _id, im_idx, layer_name
30 changes: 0 additions & 30 deletions src/napari_micromanager/_mda_meta.py

This file was deleted.

Loading
Loading