Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
48609f4
wip
fdrgsp Jan 20, 2025
880d173
.gitignore uv.lock
fdrgsp Jan 20, 2025
b78d8ba
Stop tracking uv.lock
fdrgsp Jan 20, 2025
65c2530
update viewer logic
fdrgsp Jan 21, 2025
8c8b556
uv.lock
fdrgsp Jan 22, 2025
d4ceff9
fix
fdrgsp Jan 22, 2025
fe55fdb
Merge remote-tracking branch 'upstream/main' into ndv2
fdrgsp Jan 23, 2025
e2a74a1
wip
fdrgsp Jan 23, 2025
5a9b5fb
Merge remote-tracking branch 'upstream/main' into ndv2
fdrgsp Jan 25, 2025
78bf256
wip
fdrgsp Jan 25, 2025
63cd7d1
wip
fdrgsp Jan 26, 2025
8a3fc4f
wip
fdrgsp Jan 26, 2025
b1bbd4d
wip
fdrgsp Jan 27, 2025
5720072
wealval
tlambert03 Jan 27, 2025
c6cae25
Merge pull request #1 from tlambert03/ndv2
fdrgsp Jan 28, 2025
4d6d764
pymmcore-nano
fdrgsp Jan 28, 2025
6210231
wip
fdrgsp Jan 28, 2025
83323a5
Merge remote-tracking branch 'upstream/main' into ndv2
fdrgsp Jan 28, 2025
183b190
add comment MDAWidget
fdrgsp Jan 29, 2025
c37f6db
update
fdrgsp Jan 29, 2025
a2866ad
update viewer
fdrgsp Jan 29, 2025
dc65f37
add comments
fdrgsp Jan 29, 2025
dfd706f
wip: viewer v2
fdrgsp Jan 29, 2025
a2330d9
fix
fdrgsp Jan 29, 2025
68689e1
fix: update ViewersCoreLink to also work out of MDAWidget
fdrgsp Jan 29, 2025
0754c46
fix: docstring
fdrgsp Jan 29, 2025
ea5d133
style(pre-commit.ci): auto fixes [...]
pre-commit-ci[bot] Jan 29, 2025
c29aa96
uv.lock
fdrgsp Jan 29, 2025
63540fc
uv.lock
fdrgsp Jan 29, 2025
4ebcaa9
Merge branch 'main' into ndv2
tlambert03 Jan 29, 2025
d34e56b
undo gitignore
tlambert03 Jan 29, 2025
19c78df
all the stuff we did together today
tlambert03 Jan 29, 2025
9d603b8
Merge branch 'main' into ndv2
fdrgsp Jan 29, 2025
7767aaa
uv.lock
fdrgsp Jan 29, 2025
00af7f2
remove unused
fdrgsp Jan 29, 2025
797078e
bump
tlambert03 Jan 29, 2025
ad75a65
Merge branch 'main' into ndv2
tlambert03 Jan 31, 2025
130fb07
merge in tl-ndv2
tlambert03 Feb 2, 2025
fed901b
cleanup
tlambert03 Feb 2, 2025
e88ea3c
more cleanup
tlambert03 Feb 2, 2025
0ea8673
use main ndv
tlambert03 Feb 2, 2025
715d65b
test: add test
tlambert03 Feb 2, 2025
1da51cc
back to vispy
tlambert03 Feb 2, 2025
efd6842
skip test
tlambert03 Feb 2, 2025
3e92adb
skip
tlambert03 Feb 2, 2025
7653d4c
don't test vispy
tlambert03 Feb 3, 2025
08a0a53
test: unskip again
tlambert03 Feb 3, 2025
40c68bb
unskip test
tlambert03 Feb 3, 2025
ac3482a
Merge branch 'main' into ndv2
tlambert03 Feb 5, 2025
62ab946
add cmap hook
tlambert03 Feb 5, 2025
d91672a
fix
tlambert03 Feb 5, 2025
6d0d1a0
move into new file, and support 5DBase
tlambert03 Feb 5, 2025
e12ceb7
test: remove comment
tlambert03 Feb 5, 2025
b050442
update tes
tlambert03 Feb 5, 2025
17f2fcd
revert support of 5dbase
tlambert03 Feb 5, 2025
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 @@ -57,7 +57,7 @@ repos:
- pydantic
- pydantic_core
- pymmcore
- pymmcore-plus
- pymmcore-plus>=0.13.2
- pymmcore-widgets
- pyyaml
- qtconsole
Expand Down
3 changes: 3 additions & 0 deletions app/hooks/hook-cmap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from PyInstaller.utils.hooks import collect_all

datas, binaries, hiddenimports = collect_all("cmap")
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ dev = [
"types-pyyaml>=6.0.12.20241230",
]

[tool.hatch.metadata]
allow-direct-references = true

# same as console_scripts entry point
[project.scripts]
mmgui = "pymmcore_gui._app:main"
Expand Down
2 changes: 2 additions & 0 deletions src/pymmcore_gui/_main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from pymmcore_gui.actions._core_qaction import QCoreAction
from pymmcore_gui.actions.widget_actions import WidgetActionInfo

from ._ndv_viewers import NDVViewersManager
from .actions import CoreAction, WidgetAction
from .actions._action_info import ActionKey
from .widgets._pygfx_image import PygfxImagePreview
Expand Down Expand Up @@ -114,6 +115,7 @@ def __init__(self, *, mmcore: CMMCorePlus | None = None) -> None:
self._mmc = mmc = mmcore or CMMCorePlus.instance()

self._img_preview = PygfxImagePreview(self, mmcore=self._mmc)
self._viewers_manager = NDVViewersManager(self, self._mmc)

# MENUS ====================================
# To add menus or menu items, add them to the MENUS dict above
Expand Down
155 changes: 155 additions & 0 deletions src/pymmcore_gui/_ndv_viewers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
from __future__ import annotations

import warnings
from typing import TYPE_CHECKING, cast
from weakref import WeakValueDictionary

import ndv
from pymmcore_plus.mda.handlers import TensorStoreHandler
from PyQt6.QtCore import QObject, Qt, QTimer
from PyQt6.QtWidgets import QWidget

if TYPE_CHECKING:
from collections.abc import Iterator

import numpy as np
import useq
from ndv.models._array_display_model import IndexMap
from pymmcore_plus import CMMCorePlus
from pymmcore_plus.mda import SupportsFrameReady
from pymmcore_plus.metadata import FrameMetaV1, SummaryMetaV1
from useq import MDASequence


# NOTE: we make this a QObject mostly so that the lifetime of this object is tied to
# the lifetime of the parent QMainWindow. If inheriting from QObject is removed in
# the future, make sure not to store a strong reference to this main_window
class NDVViewersManager(QObject):
"""Object that mediates a connection between the MDA experiment and ndv viewers.

Parameters
----------
parent : QWidget
The parent widget.
mmcore : CMMCorePlus
The CMMCorePlus instance.
"""

def __init__(self, parent: QWidget, mmcore: CMMCorePlus):
super().__init__(parent)
self._mmc = mmcore

# weakref map of {sequence_uid: ndv.ArrayViewer}
self._seq_viewers = WeakValueDictionary[str, ndv.ArrayViewer]()
# currently active viewer
self._active_viewer: ndv.ArrayViewer | None = None

# We differentiate between handlers that were created by someone else, and
# gathered using mda.get_output_handlers(), vs handlers that were created by us.
# because we need to call frameReady/sequenceFinished manually on the latter.
self._handler: SupportsFrameReady | None = None
self._own_handler: TensorStoreHandler | None = None

# CONNECTIONS ---------------------------------------------------------

self._mmc.mda.events.sequenceStarted.connect(self._on_sequence_started)
self._mmc.mda.events.frameReady.connect(self._on_frame_ready)
self._mmc.mda.events.sequenceFinished.connect(self._on_sequence_finished)
parent.destroyed.connect(self._cleanup)

def _cleanup(self, obj: QObject | None = None) -> None:
self._active_viewer = None
self._handler = None
self._own_handler = None

def _on_sequence_started(
self, sequence: useq.MDASequence, meta: SummaryMetaV1
) -> None:
"""Called when a new MDA sequence has been started.

We grab the first handler in the list of output handlers, or create a new
TensorStoreHandler if none exist. Then we create a new ndv viewer and show it.
"""
self._own_handler = self._handler = None
if handlers := self._mmc.mda.get_output_handlers():
# someone else has created a handler for this sequence
self._handler = handlers[0]

Check warning on line 76 in src/pymmcore_gui/_ndv_viewers.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_gui/_ndv_viewers.py#L76

Added line #L76 was not covered by tests
else:
# if it does not exist, create a new TensorStoreHandler
self._own_handler = TensorStoreHandler(driver="zarr", kvstore="memory://")
self._own_handler.reset(sequence)

# since the handler is empty at this point, create a ndv viewer with no data
self._active_viewer = viewer = self._create_ndv_viewer(sequence)
self._seq_viewers[str(sequence.uid)] = viewer

def _on_frame_ready(
self, frame: np.ndarray, event: useq.MDAEvent, meta: FrameMetaV1
) -> None:
"""Create a viewer if it does not exist, otherwise update the current index."""
# at this point the viewer should exist
if self._own_handler is not None:
self._own_handler.frameReady(frame, event, meta)

if (viewer := self._active_viewer) is None:
return # pragma: no cover

# if the viewer does not yet have data, it's likely the very first frame
# so update the viewer's data source to the underlying handlers store
if viewer.data_wrapper is None:
handler = self._handler or self._own_handler
if isinstance(handler, TensorStoreHandler):
# TODO: temporary. maybe create the DataWrapper for the handlers
viewer.data = handler.store
else:
warnings.warn(

Check warning on line 105 in src/pymmcore_gui/_ndv_viewers.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_gui/_ndv_viewers.py#L105

Added line #L105 was not covered by tests
f"don't know how to show data of type {type(handler)}",
stacklevel=2,
)
# otherwise update the sliders to the most recently acquired frame
else:
# Add a small delay to make sure the data are available in the handler
# This is a bit of a hack to get around the data handlers can write data
# asynchronously, so the data may not be available immediately to the viewer
# after the handler's frameReady method is called.
current_index = viewer.display_model.current_index

def _update(_idx: IndexMap = current_index) -> None:
try:
_idx.update(event.index.items())
except Exception: # pragma: no cover
# this happens if the viewer has been closed in the meantime
# usually it's a RuntimeError, but could be an EmitLoopError
pass

QTimer.singleShot(10, _update)

def _on_sequence_finished(self, sequence: useq.MDASequence) -> None:
"""Called when a sequence has finished."""
if self._own_handler is not None:
self._own_handler.sequenceFinished(sequence)
# cleanup pointers somehow?

def _create_ndv_viewer(self, sequence: MDASequence) -> ndv.ArrayViewer:
"""Create a new ndv viewer with no data."""
ndv_viewer = ndv.ArrayViewer()
q_viewer = cast("QWidget", ndv_viewer.widget())

if isinstance(par := self.parent(), QWidget):
q_viewer.setParent(par)

sha = str(sequence.uid)[:8]
q_viewer.setObjectName(f"ndv-{sha}")
q_viewer.setWindowTitle(f"MDA {sha}")
q_viewer.setWindowFlags(Qt.WindowType.Dialog)
q_viewer.show()
return ndv_viewer

def __repr__(self) -> str: # pragma: no cover
return f"<{self.__class__.__name__} {hex(id(self))} ({len(self)} viewer)>"

def __len__(self) -> int:
return len(self._seq_viewers)

def viewers(self) -> Iterator[ndv.ArrayViewer]:
yield from (self._seq_viewers.values())

Check warning on line 155 in src/pymmcore_gui/_ndv_viewers.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_gui/_ndv_viewers.py#L155

Added line #L155 was not covered by tests
1 change: 1 addition & 0 deletions src/pymmcore_gui/actions/widget_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ def create_install_widgets(parent: QWidget) -> pmmw.InstallWidget:

def create_mda_widget(parent: QWidget) -> pmmw.MDAWidget:
"""Create the MDA widget."""
# from pymmcore_gui.widgets import _MDAWidget
from pymmcore_widgets import MDAWidget

return MDAWidget(parent=parent, mmcore=_get_core(parent))
Expand Down
50 changes: 50 additions & 0 deletions tests/test_ndv_viewers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from __future__ import annotations

import datetime
import gc
from typing import TYPE_CHECKING

import pytest
import useq
from PyQt6.QtWidgets import QApplication, QWidget
from useq import MDASequence

from pymmcore_gui._ndv_viewers import NDVViewersManager

if TYPE_CHECKING:
from pymmcore_plus import CMMCorePlus
from pytestqt.qtbot import QtBot


def test_viewers_manager(mmcore: CMMCorePlus, qtbot: QtBot) -> None:
"""Ensure that the viewers manager creates and cleans up viewers during MDA."""
dummy = QWidget()
manager = NDVViewersManager(dummy, mmcore)

assert len(manager) == 0
mmcore.mda.run(
MDASequence(
time_plan=useq.TIntervalLoops(
interval=datetime.timedelta(seconds=0.1), loops=2
),
channels=["DAPI", "FITC"], # pyright: ignore
z_plan=useq.ZRangeAround(range=4, step=1),
),
)
assert len(manager) == 1

with qtbot.waitSignal(dummy.destroyed, timeout=1000):
dummy.deleteLater()
QApplication.processEvents()
QApplication.processEvents()
gc.collect()
gc.collect()
if len(manager):
for viewer in manager.viewers():
if "vispy" in type(viewer._canvas).__name__.lower():
# don't even bother... vispy is a mess of hard references
del viewer._canvas
del viewer._histogram
continue
referrers = gc.get_referrers(viewer)[1:]
pytest.fail(f"Viewer {viewer} not deleted. Still referenced by {referrers}")