Skip to content

Commit

Permalink
feat: use new pymmcore-widgets useq-widget (#292)
Browse files Browse the repository at this point in the history
* fix: use useq wdg

* fix: _determine_sequence_layers

* fix: _has_sub_sequences

* fix: update mda setValue

* test: update

* fix: _determine_sequence_layers

* fix: pre-commit

* fix: delete old stuff

* fix: fix import

* fix: fix launch-dev

* bump version of pymmcore-widgets

* remove save widet

* cleanup

* add bool

* tests: fix test

* cleanup value

* fix pre commit

* another cleanup

---------

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
  • Loading branch information
fdrgsp and tlambert03 authored Oct 14, 2023
1 parent 0e0b7a5 commit df1b9af
Show file tree
Hide file tree
Showing 9 changed files with 92 additions and 208 deletions.
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ repos:
- id: mypy
files: "^src/"
additional_dependencies:
- useq-schema >=0.3.0
- useq-schema >=0.4.7
- pymmcore_plus >=0.6.7
# # unfortunately... bringing these in brings in qtpy
# # which has too many attr-defined errors ...
Expand Down
4 changes: 2 additions & 2 deletions launch-dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@
)

main_window._show_dock_widget("MDA")
mda = v.window._dock_widgets.get("MDA").widget()
mda.set_state(sequence)
mda_widget = v.window._dock_widgets.get("MDA").widget()
mda_widget.setValue(sequence)

# fill napari-console with useful variables
v.window._qt_viewer.console.push(
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ dependencies = [
"fonticon-materialdesignicons6",
"napari >=0.4.13",
"pymmcore-plus >=0.8.0",
"pymmcore-widgets >=0.4.2",
"pymmcore-widgets >=0.5.3",
"superqt >=0.5.1",
"tifffile",
"useq-schema >=0.4.1",
Expand Down
139 changes: 41 additions & 98 deletions src/napari_micromanager/_gui_objects/_mda_widget.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,21 @@
from __future__ import annotations

import warnings
from pathlib import Path
from typing import TYPE_CHECKING, cast

from pymmcore_widgets import MDAWidget
from qtpy.QtCore import Qt
from pymmcore_widgets.mda import MDAWidget
from qtpy.QtWidgets import (
QCheckBox,
QGridLayout,
QMessageBox,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from useq import MDASequence

from napari_micromanager._mda_meta import SEQUENCE_META_KEY, SequenceMeta

from ._save_widget import SaveWidget

if TYPE_CHECKING:
from pymmcore_plus import CMMCorePlus
from useq import MDASequence

MMCORE_WIDGETS_META = "pymmcore_widgets"


class MultiDWidget(MDAWidget):
Expand All @@ -30,97 +24,46 @@ class MultiDWidget(MDAWidget):
def __init__(
self, *, parent: QWidget | None = None, mmcore: CMMCorePlus | None = None
) -> None:
super().__init__(include_run_button=True, parent=parent, mmcore=mmcore)
# add save widget
v_layout = cast(QVBoxLayout, self._central_widget.layout())
self._save_groupbox = SaveWidget()
self._save_groupbox.setSizePolicy(
QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed
)
self._save_groupbox.setChecked(False)
self._save_groupbox.toggled.connect(self._on_save_toggled)
self._save_groupbox._directory.textChanged.connect(self._on_save_toggled)
self._save_groupbox._fname.textChanged.connect(self._on_save_toggled)
v_layout.insertWidget(0, self._save_groupbox)

# add split channel checkbox
self.channel_widget.setMinimumHeight(230)
self.checkBox_split_channels = QCheckBox(text="Split Channels")
self.checkBox_split_channels.toggled.connect(self._toggle_split_channel)
g_layout = cast(QGridLayout, self.channel_widget.layout())
g_layout.addWidget(self.checkBox_split_channels, 1, 0)
self.channel_widget.valueChanged.connect(self._toggle_split_channel)
super().__init__(parent=parent, mmcore=mmcore)

# setContentsMargins
pos_layout = cast("QVBoxLayout", self.stage_positions.layout())
pos_layout.setContentsMargins(10, 10, 10, 10)
time_layout = cast("QVBoxLayout", self.time_plan.layout())
time_layout.setContentsMargins(10, 10, 10, 10)
ch_layout = cast("QVBoxLayout", self.channels.layout())
ch_layout.setContentsMargins(10, 10, 10, 10)
ch_layout.addWidget(self.checkBox_split_channels)

def value(self) -> MDASequence:
"""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

def _toggle_split_channel(self) -> None:
if (
not self.channel_widget.value()
or self.channel_widget._table.rowCount() == 1
):
self.checkBox_split_channels.setChecked(False)

def _on_save_toggled(self) -> None:
if self.position_widget.value():
self._save_groupbox._split_pos_checkbox.setEnabled(True)

else:
self._save_groupbox._split_pos_checkbox.setCheckState(
Qt.CheckState.Unchecked
)
self._save_groupbox._split_pos_checkbox.setEnabled(False)

def get_state(self) -> MDASequence:
sequence = cast(MDASequence, super().get_state())
sequence.metadata[SEQUENCE_META_KEY] = SequenceMeta(
mode="mda",
split_channels=self.checkBox_split_channels.isChecked(),
**self._save_groupbox.get_state(),
)
return sequence

def set_state(self, state: dict | MDASequence | str | Path) -> None:
super().set_state(state)
meta = None
if isinstance(state, dict):
meta = state.get("metadata", {}).get(SEQUENCE_META_KEY)
elif isinstance(state, MDASequence):
meta = state.metadata.get(SEQUENCE_META_KEY)

if meta is None:
return
if not isinstance(meta, SequenceMeta):
raise TypeError(f"Expected {SequenceMeta}, got {type(meta)}")
if meta.mode.lower() != "mda":
raise ValueError(f"Expected mode 'mda', got {meta.mode}")

self.checkBox_split_channels.setChecked(meta.split_channels)
self._save_groupbox.set_state(meta)

def _on_run_clicked(self) -> None:
if (
self._save_groupbox.isChecked()
and not self._save_groupbox._directory.text()
):
warnings.warn("Select a directory to save the data.", stacklevel=2)
return

if not Path(self._save_groupbox._directory.text()).exists():
if self._create_new_folder():
Path(self._save_groupbox._directory.text()).mkdir(parents=True)
else:
return

super()._on_run_clicked()

def _create_new_folder(self) -> bool:
"""Create a QMessageBox to ask to create directory if it doesn't exist."""
msgBox = QMessageBox()
msgBox.setWindowTitle("Create Directory")
msgBox.setIcon(QMessageBox.Icon.Question)
msgBox.setText(
f"Directory {self._save_groupbox._directory.text()} "
"does not exist. Create it?"
)
msgBox.setStandardButtons(
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
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 bool(msgBox.exec() == QMessageBox.StandardButton.Ok)
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 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)
72 changes: 0 additions & 72 deletions src/napari_micromanager/_gui_objects/_save_widget.py

This file was deleted.

37 changes: 29 additions & 8 deletions src/napari_micromanager/_mda_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@

from ._mda_meta import SEQUENCE_META_KEY, SequenceMeta
from ._saving import save_sequence
from ._util import get_axis_labels

if TYPE_CHECKING:
from uuid import UUID
Expand Down Expand Up @@ -54,6 +53,24 @@ 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)


class _NapariMDAHandler:
"""Object mediating events between an in-progress MDA and the napari viewer.
Expand Down Expand Up @@ -332,17 +349,21 @@ def _determine_sequence_layers(
# each item is a tuple of (id, shape, layer_metadata)
_layer_info: list[tuple[str, list[int], dict[str, Any]]] = []

axis_labels = get_axis_labels(sequence)

layer_shape = [sequence.sizes[k] or 1 for k in axis_labels]
axis_labels = list(get_full_sequence_axes(sequence))
layer_shape = [sequence.sizes.get(k) or 1 for k in axis_labels]

if _has_sub_sequences(sequence):
for p in sequence.stage_positions:
if not p.sequence:
continue
pos_g_shape = p.sequence.sizes["g"]
index = axis_labels.index("g")
layer_shape[index] = max(layer_shape[index], pos_g_shape)

# update the layer shape for the c, g, z and t axis depending on the shape
# of the sub sequence (sub-sequence can only have c, g, z and t).
for key in "cgzt":
with contextlib.suppress(KeyError, ValueError):
pos_shape = p.sequence.sizes[key]
index = axis_labels.index(key)
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:
Expand Down Expand Up @@ -382,7 +403,7 @@ def _id_idx_layer(event: ActiveMDAEvent) -> tuple[str, tuple[int, ...], str]:
"""
meta = cast("SequenceMeta", event.sequence.metadata.get(SEQUENCE_META_KEY))

axis_order = get_axis_labels(event.sequence)
axis_order = list(get_full_sequence_axes(event.sequence))

suffix = ""
prefix = meta.file_name if meta.should_save else "Exp"
Expand Down
4 changes: 2 additions & 2 deletions src/napari_micromanager/_mda_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ class SequenceMeta:

mode: str = ""
split_channels: bool = False
should_save: bool = False
file_name: str = ""
save_dir: str = ""
save_pos: bool = False
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."""
Expand Down
18 changes: 0 additions & 18 deletions src/napari_micromanager/_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
if TYPE_CHECKING:
from pathlib import Path

from useq import MDASequence


def ensure_unique(path: Path, extension: str = ".tif", ndigits: int = 3) -> Path:
"""Get next suitable filepath (extension = ".tif") or folderpath (extension = "").
Expand Down Expand Up @@ -38,19 +36,3 @@ def ensure_unique(path: Path, extension: str = ".tif", ndigits: int = 3) -> Path
# build new path name
number = f"_{current_max+1:0{ndigits}d}"
return path.parent / f"{stem}{number}{extension}"


def get_axis_labels(sequence: MDASequence) -> list[str]:
"""Get the MDASequence axis labels using only axes that are present in events."""
# axis main sequence
main_seq_axis = list(sequence.used_axes)
if not sequence.stage_positions:
return main_seq_axis
# axes from sub sequences
sub_seq_axis: list = []
for p in sequence.stage_positions:
if p.sequence is not None:
sub_seq_axis.extend(
[ax for ax in p.sequence.used_axes if ax not in main_seq_axis]
)
return main_seq_axis + sub_seq_axis
Loading

0 comments on commit df1b9af

Please sign in to comment.