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

[WIP] feat: Stage explorer #400

Open
wants to merge 115 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
115 commits
Select commit Hold shift + click to select a range
29a7557
feat: wip add stage explorer
fdrgsp Nov 27, 2024
b993122
feat: add properties + example
fdrgsp Nov 27, 2024
ff302b0
fix: auto_reset_view
fdrgsp Nov 27, 2024
811d96c
fix: update
fdrgsp Nov 27, 2024
3bb6df1
fix: _on_pixel_size_changed + rename
fdrgsp Nov 27, 2024
570ef9a
fix: DataStore get_image
fdrgsp Nov 27, 2024
724fdf1
fix: _on_setting_checked + rename
fdrgsp Nov 27, 2024
8d18acd
fix: remove _draw_scale_info
fdrgsp Nov 28, 2024
1efb902
fix: remove core from StageViewer and add it only to StageExplorer
fdrgsp Nov 28, 2024
5290dbf
fix: simplify StageViewer and move logic to StageExplorer
fdrgsp Nov 28, 2024
fb9fc83
fix: text
fdrgsp Nov 28, 2024
3bccb86
fix: reset_view
fdrgsp Nov 28, 2024
5984b33
fix: use float
fdrgsp Nov 28, 2024
f7a30cd
fix: remove swttings btn and use toolsbtns
fdrgsp Nov 28, 2024
9b98c4d
fix: remove swttings btn and use toolsbtns
fdrgsp Nov 28, 2024
14bbdf3
fix
fdrgsp Nov 28, 2024
48e755f
fix: use generator
fdrgsp Nov 28, 2024
9ebb34a
fix: remove print
fdrgsp Nov 29, 2024
3c1091c
fix: remove DataStore and use dict
fdrgsp Nov 29, 2024
d6c3ebe
fix: SS_TOOLBUTTON
fdrgsp Nov 29, 2024
c7e0382
fix: Reset View
fdrgsp Nov 29, 2024
7c96933
fix: map_marker
fdrgsp Nov 29, 2024
5a7d03c
fix: pos stage label
fdrgsp Nov 29, 2024
232c753
fix: example
fdrgsp Nov 29, 2024
c8bad47
fix: update ss
fdrgsp Nov 30, 2024
51128ec
wip
fdrgsp Nov 30, 2024
2d994a0
fix: not snap while mda is running
fdrgsp Dec 4, 2024
7b7a704
fix: _poll_stage_position
fdrgsp Dec 4, 2024
f4c55ad
fix: parent
fdrgsp Dec 5, 2024
58bab22
fix: example of crash
fdrgsp Dec 5, 2024
2e5bacb
fix: example of crash
fdrgsp Dec 5, 2024
312eab4
fix: remove unused
fdrgsp Dec 5, 2024
07334d1
Merge branch 'pymmcore-plus:main' into stage-explorer
fdrgsp Dec 6, 2024
0b7447a
wip
Dec 6, 2024
d0dc640
feat: use Rectangle
fdrgsp Dec 7, 2024
71fcdf4
fix: better handle reset_view
fdrgsp Dec 7, 2024
475852f
fix: remove auto_reset_view
fdrgsp Dec 7, 2024
7f84113
fix: fix pixel size
fdrgsp Dec 7, 2024
bdfabc1
fix: scale
fdrgsp Dec 7, 2024
4a2b9ba
fix: is_running
fdrgsp Dec 7, 2024
a7241f2
fix: remove print
fdrgsp Dec 7, 2024
adc29a1
fix: flip
fdrgsp Dec 7, 2024
848370d
fix: don't use private attr
fdrgsp Dec 8, 2024
ecf5433
fix: _is_image_within_view
fdrgsp Dec 8, 2024
11de99f
fix: wip
fdrgsp Dec 9, 2024
cac5fae
fix: add vispy
fdrgsp Dec 9, 2024
cc31110
wip
fdrgsp Dec 12, 2024
cdb827b
fix: example
fdrgsp Dec 12, 2024
12f14c8
fix: add MIN_XY_DIFF to reset view when polling
fdrgsp Dec 21, 2024
b5ff070
wip: rotation
fdrgsp Jan 2, 2025
8f8fbd6
fix: addSeparator
fdrgsp Jan 3, 2025
0426604
test: add StageViewer tests
fdrgsp Jan 3, 2025
27c6662
Merge branch 'pymmcore-plus:main' into stage-explorer
fdrgsp Jan 3, 2025
78aaa97
fix: update
fdrgsp Jan 4, 2025
12e2494
fix: rotation
fdrgsp Jan 4, 2025
446d419
fix: reorder
fdrgsp Jan 4, 2025
481aa2f
fix: update marker logic
fdrgsp Jan 4, 2025
27f3294
fix: wip _is_image_within_view
fdrgsp Jan 4, 2025
327c2bd
Merge remote-tracking branch 'upstream/main' into stage-explorer
fdrgsp Feb 8, 2025
93d6856
style(pre-commit.ci): auto fixes [...]
pre-commit-ci[bot] Feb 11, 2025
0203f21
Merge branch 'main' into stage-explorer
fdrgsp Feb 11, 2025
5e86861
fix flip
fdrgsp Feb 18, 2025
fd6cd84
handle rotation of stage pos marker
fdrgsp Feb 18, 2025
a581260
_handle_rotation
fdrgsp Feb 18, 2025
cdc2904
remove print
fdrgsp Feb 18, 2025
34673e7
updatre rotation
fdrgsp Feb 18, 2025
9e77238
wip roi
fdrgsp Feb 19, 2025
9833179
wip roi
fdrgsp Feb 19, 2025
5a150fe
wip multi roi
fdrgsp Feb 19, 2025
c3e1180
fix
fdrgsp Feb 19, 2025
b647e9c
fix
fdrgsp Feb 19, 2025
df6d9df
rectChanged
fdrgsp Feb 19, 2025
728066b
style(pre-commit.ci): auto fixes [...]
pre-commit-ci[bot] Feb 19, 2025
065a0e4
wip
fdrgsp Feb 20, 2025
b253609
wip
fdrgsp Feb 20, 2025
b422f0a
wip
fdrgsp Feb 20, 2025
26f004b
wip
fdrgsp Feb 20, 2025
173ec5c
style(pre-commit.ci): auto fixes [...]
pre-commit-ci[bot] Feb 20, 2025
eeea3b3
scale
fdrgsp Feb 21, 2025
d6f9be5
wip
fdrgsp Feb 21, 2025
4e40fba
wip
fdrgsp Feb 21, 2025
91d48c4
flip
fdrgsp Feb 21, 2025
5d01b2d
style(pre-commit.ci): auto fixes [...]
pre-commit-ci[bot] Feb 21, 2025
7848068
fix flip
fdrgsp Feb 21, 2025
adf0b85
example
fdrgsp Feb 21, 2025
fdd01f6
fix flip
Feb 21, 2025
79b394a
style(pre-commit.ci): auto fixes [...]
pre-commit-ci[bot] Feb 21, 2025
e08bbba
fix flip
Feb 21, 2025
a80915b
style(pre-commit.ci): auto fixes [...]
pre-commit-ci[bot] Feb 21, 2025
2303e35
remove print
fdrgsp Feb 22, 2025
f68744b
fix get x y from matrix
fdrgsp Feb 22, 2025
35dd645
fix _get_full_boundaries
fdrgsp Feb 23, 2025
fe0f7ec
remove unused
fdrgsp Feb 23, 2025
b785d50
roi
fdrgsp Feb 23, 2025
c0c7c8d
reorder
fdrgsp Feb 23, 2025
9d7629a
wip roi
fdrgsp Feb 23, 2025
cb955c3
cursor
fdrgsp Feb 23, 2025
006814b
comment + (dis)connect
fdrgsp Feb 23, 2025
cbd3eea
update rois
fdrgsp Feb 23, 2025
619bdda
update value()
fdrgsp Feb 23, 2025
341f59e
roi text
fdrgsp Feb 24, 2025
74f4eea
fix GridFromEdges
fdrgsp Feb 25, 2025
5a2ac78
_build_grid_plan
fdrgsp Feb 25, 2025
e3dcab7
update timerEvent
fdrgsp Feb 25, 2025
073fd79
style(pre-commit.ci): auto fixes [...]
pre-commit-ci[bot] Feb 25, 2025
627ffa7
TODO
fdrgsp Feb 25, 2025
cac6c86
increase marker pixels
fdrgsp Feb 25, 2025
d1f7d54
move roi logic to explorer
fdrgsp Feb 26, 2025
bd951b5
text
fdrgsp Feb 26, 2025
5b3b25e
fix section
fdrgsp Feb 26, 2025
09b6bbd
rois btns
fdrgsp Feb 26, 2025
a0021ae
fix RotationControl
fdrgsp Feb 26, 2025
8e4686d
fix _get_full_boundaries + stage marker
Feb 27, 2025
b3045b9
style(pre-commit.ci): auto fixes [...]
pre-commit-ci[bot] Feb 27, 2025
a02e3b1
Merge branch 'pymmcore-plus:main' into stage-explorer
fdrgsp Mar 3, 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
45 changes: 45 additions & 0 deletions examples/stage_explorer_widget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from pymmcore_plus import CMMCorePlus
from qtpy.QtWidgets import QApplication

from pymmcore_widgets import GroupPresetTableWidget, StageWidget
from pymmcore_widgets.control import StageExplorer

app = QApplication([])

mmc = CMMCorePlus.instance()
mmc.loadSystemConfiguration()
# delete px size affine
mmc.setPixelSizeAffine("Res10x", [1.0, 0.0, 0.0, 0.0, 1.0, 0.0])
mmc.setPixelSizeAffine("Res20x", [1.0, 0.0, 0.0, 0.0, 1.0, 0.0])
mmc.setPixelSizeAffine("Res40x", [1.0, 0.0, 0.0, 0.0, 1.0, 0.0])

# set camera roi
mmc.setROI(0, 0, 512, 256)
# mmc.setROI(0, 0, 256, 512)

wdg = StageExplorer()
wdg.poll_stage_position = True
wdg.scaleChanged.connect(lambda x: print(f"Scale changed to {x}"))


def _print_stage_position():
print(wdg.value())


wdg.rectChanged.connect(_print_stage_position)

wdg.show()

stage = StageWidget("XY")
stage.setStep(512)
stage.snap_checkbox.setChecked(True)
stage.show()

# MDA = MDAWidget()
# MDA.show()

gp = GroupPresetTableWidget()
gp.show()

v = wdg._stage_viewer
# app.exec()
45 changes: 45 additions & 0 deletions examples/stage_explorer_widget_wip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from pymmcore_plus import CMMCorePlus
from qtpy.QtWidgets import QApplication

from pymmcore_widgets import GroupPresetTableWidget, MDAWidget, StageWidget
from pymmcore_widgets.control._stage_explorer._stage_explorer_wip import StageExplorer

app = QApplication([])

mmc = CMMCorePlus.instance()
mmc.loadSystemConfiguration()
# delete px size affine
mmc.setPixelSizeAffine("Res10x", [1.0, 0.0, 0.0, 0.0, 1.0, 0.0])
mmc.setPixelSizeAffine("Res20x", [1.0, 0.0, 0.0, 0.0, 1.0, 0.0])
mmc.setPixelSizeAffine("Res40x", [1.0, 0.0, 0.0, 0.0, 1.0, 0.0])

# set camera roi
mmc.setROI(0, 0, 512, 256)
# mmc.setROI(0, 0, 256, 512)

wdg = StageExplorer()
wdg.poll_stage_position = True
# wdg.scaleChanged.connect(lambda x: print(f"Scale changed to {x}"))


def _print_stage_position():
print(wdg.value())


# wdg.rectChanged.connect(_print_stage_position)

wdg.show()

stage = StageWidget("XY")
stage.setStep(512)
stage.snap_checkbox.setChecked(True)
stage.show()

MDA = MDAWidget()
MDA.show()

gp = GroupPresetTableWidget()
gp.show()

v = wdg._stage_viewer
app.exec()
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ dependencies = [
'qtpy >=2.0',
'superqt[quantity,cmap] >=0.7.1',
'useq-schema >=0.5.0',
'vispy'
]

[tool.hatch.metadata]
Expand Down
2 changes: 2 additions & 0 deletions src/pymmcore_widgets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"PropertyWidget",
"ShuttersWidget",
"SnapButton",
"StageExplorer",
"StageWidget",
"StateDeviceWidget",
"TimePlanWidget",
Expand All @@ -61,6 +62,7 @@
PresetsWidget,
ShuttersWidget,
SnapButton,
StageExplorer,
StageWidget,
)
from .device_properties import PropertiesWidget, PropertyBrowser, PropertyWidget
Expand Down
2 changes: 2 additions & 0 deletions src/pymmcore_widgets/control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from ._presets_widget import PresetsWidget
from ._shutter_widget import ShuttersWidget
from ._snap_button_widget import SnapButton
from ._stage_explorer import StageExplorer
from ._stage_widget import StageWidget

__all__ = [
Expand All @@ -24,5 +25,6 @@
"PresetsWidget",
"ShuttersWidget",
"SnapButton",
"StageExplorer",
"StageWidget",
]
3 changes: 3 additions & 0 deletions src/pymmcore_widgets/control/_stage_explorer/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from ._stage_explorer import StageExplorer

__all__ = ["StageExplorer"]
256 changes: 256 additions & 0 deletions src/pymmcore_widgets/control/_stage_explorer/_rois.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
from collections.abc import Sequence
from enum import Enum
from typing import Any

import numpy as np
import vispy.color
from qtpy.QtCore import Qt
from qtpy.QtGui import QCursor
from vispy import scene
from vispy.app.canvas import MouseEvent


class ROIMoveMode(Enum):
"""ROI modes."""

NONE = "none" # No movement
DRAW = "draw" # Drawing a new ROI
HANDLE = "handle" # Moving a handle
TRANSLATE = "translate" # Translating the whole ROI


class ROIRectangle:
"""A rectangle ROI."""

def __init__(self, parent: Any) -> None:
# flag to indicate if the ROI is selected
self._selected = False
# flag to indicate the move mode
self._move_mode: ROIMoveMode = ROIMoveMode.DRAW
# anchor point for the move mode
self._move_anchor: tuple[float, float] = (0, 0)

self._rect = scene.Rectangle(
center=[0, 0],
width=1,
height=1,
color=None,
border_color=vispy.color.Color("yellow"),
border_width=2,
parent=parent,
)
self._rect.set_gl_state(depth_test=False)
self._rect.interactive = True

self._handle_data = np.zeros((4, 2))
self._handle_size = 20 # px
self._handles = scene.Markers(
pos=self._handle_data,
size=self._handle_size,
scaling=False, # "fixed"
face_color=vispy.color.Color("white"),
parent=parent,
)
self._handles.set_gl_state(depth_test=False)
self._handles.interactive = True

# Add text at the center of the rectangle
self._text = scene.Text(
text="",
bold=True,
color="yellow",
font_size=12,
anchor_x="center",
anchor_y="center",
depth_test=False,
parent=parent,
)

self.set_visible(False)

@property
def center(self) -> tuple[float, float]:
"""Return the center of the ROI."""
return tuple(self._rect.center)

# ---------------------PUBLIC METHODS---------------------

def visible(self) -> bool:
"""Return whether the ROI is visible."""
return bool(self._rect.visible)

def set_visible(self, visible: bool) -> None:
"""Set the ROI as visible."""
self._rect.visible = visible
self._handles.visible = visible and self.selected()
self._text.visible = visible

def selected(self) -> bool:
"""Return whether the ROI is selected."""
return self._selected

def set_selected(self, selected: bool) -> None:
"""Set the ROI as selected."""
self._selected = selected
self._handles.visible = selected and self.visible()
self._text.visible = selected

def remove(self) -> None:
"""Remove the ROI from the scene."""
self._rect.parent = None
self._handles.parent = None
self._text.parent = None

def set_anchor(self, pos: tuple[float, float]) -> None:
"""Set the anchor of the ROI.

The anchor is the point where the ROI is created or moved from.
"""
self._move_anchor = pos

def set_text(self, text: str) -> None:
"""Set the text of the ROI."""
self._text.text = text

def bounding_box(self) -> tuple[tuple[float, float], tuple[float, float]]:
"""Return the bounding box of the ROI as top-left and bottom-right corners."""
x1 = self._rect.center[0] - self._rect.width / 2
y1 = self._rect.center[1] + self._rect.height / 2
x2 = self._rect.center[0] + self._rect.width / 2
y2 = self._rect.center[1] - self._rect.height / 2
return (x1, y1), (x2, y2)

def set_bounding_box(
self, mi: tuple[float, float], ma: tuple[float, float]
) -> None:
"""Set the bounding box of the ROI using two diagonal points."""
x1 = float(min(mi[0], ma[0]))
y1 = float(min(mi[1], ma[1]))
x2 = float(max(mi[0], ma[0]))
y2 = float(max(mi[1], ma[1]))
# update rectangle
self._rect.center = [(x1 + x2) / 2, (y1 + y2) / 2]
self._rect.width = max(float(x2 - x1), 1e-30)
self._rect.height = max(float(y2 - y1), 1e-30)
# update handles
self._handle_data[0] = x1, y1
self._handle_data[1] = x2, y1
self._handle_data[2] = x2, y2
self._handle_data[3] = x1, y2
self._handles.set_data(pos=self._handle_data)

self._text.pos = self._rect.center

def get_cursor(self, event: MouseEvent) -> QCursor | None:
"""Return the cursor shape depending on the mouse position.

If the mouse is over a handle, return a cursor indicating that the handle can be
dragged. If the mouse is over the rectangle, return a cursor indicating that th
whole ROI can be moved. Otherwise, return the default cursor.
"""
canvas_pos = (event.pos[0], event.pos[1])
pos = self._tform().map(canvas_pos)[:2]
if (idx := self._under_mouse_index(pos)) is not None:
# if the mouse is over the rectangle, return a SizeAllCursor cursor
# indicating that the whole ROI can be moved
if idx == -1:
return QCursor(Qt.CursorShape.SizeAllCursor)
# if the mouse is over a handle, return a cursor indicating that the handle
# can be dragged
elif idx >= 0:
return QCursor(Qt.CursorShape.DragMoveCursor)
# otherwise, return the default cursor
else:
return QCursor(Qt.CursorShape.ArrowCursor)
return QCursor(Qt.CursorShape.ArrowCursor)

def connect(self, canvas: scene.SceneCanvas) -> None:
"""Connect the ROI events to the canvas."""
canvas.events.mouse_press.connect(self.on_mouse_press)
canvas.events.mouse_move.connect(self.on_mouse_move)
canvas.events.mouse_release.connect(self.on_mouse_release)

def disconnect(self, canvas: scene.SceneCanvas) -> None:
"""Disconnect the ROI events from the canvas."""
canvas.events.mouse_press.disconnect(self.on_mouse_press)
canvas.events.mouse_move.disconnect(self.on_mouse_move)
canvas.events.mouse_release.disconnect(self.on_mouse_release)

# ---------------------MOUSE EVENTS---------------------

# for canvas.events.mouse_press.connect
def on_mouse_press(self, event: MouseEvent) -> None:
"""Handle the mouse press event."""
canvas_pos = (event.pos[0], event.pos[1])
world_pos = self._tform().map(canvas_pos)[:2]

# check if the mouse is over a handle or the rectangle
idx = self._under_mouse_index(world_pos)

# if the mouse is over a handle, set the move mode to HANDLE
if idx is not None and idx >= 0:
self.set_selected(True)
opposite_idx = (idx + 2) % 4
self._move_mode = ROIMoveMode.HANDLE
self._move_anchor = tuple(self._handle_data[opposite_idx].copy())
# if the mouse is over the rectangle, set the move mode to
elif idx == -1:
self.set_selected(True)
self._move_mode = ROIMoveMode.TRANSLATE
self._move_anchor = world_pos
# if the mouse is not over a handle or the rectangle, set the move mode to
else:
self.set_selected(False)
self._move_mode = ROIMoveMode.NONE

# for canvas.events.mouse_move.connect
def on_mouse_move(self, event: MouseEvent) -> None:
"""Handle the mouse drag event."""
# convert canvas -> world
canvas_pos = (event.pos[0], event.pos[1])
world_pos = self._tform().map(canvas_pos)[:2]
# drawing a new roi
if self._move_mode == ROIMoveMode.DRAW:
self.set_bounding_box(self._move_anchor, world_pos)
# moving a handle
elif self._move_mode == ROIMoveMode.HANDLE:
# The anchor is set to the opposite handle, which never moves.
self.set_bounding_box(self._move_anchor, world_pos)
# translating the whole roi
elif self._move_mode == ROIMoveMode.TRANSLATE:
# The anchor is the mouse position reported in the previous mouse event.
dx = world_pos[0] - self._move_anchor[0]
dy = world_pos[1] - self._move_anchor[1]
# If the mouse moved (dx, dy) between events, the whole ROI needs to be
# translated that amount.
new_min = (self._handle_data[0, 0] + dx, self._handle_data[0, 1] + dy)
new_max = (self._handle_data[2, 0] + dx, self._handle_data[2, 1] + dy)
self._move_anchor = world_pos
self.set_bounding_box(new_min, new_max)

# for canvas.events.mouse_release.connect
def on_mouse_release(self, event: MouseEvent) -> None:
"""Handle the mouse release event."""
self._move_mode = ROIMoveMode.NONE

# ---------------------PRIVATE METHODS---------------------

def _tform(self) -> scene.transforms.BaseTransform:
return self._rect.transforms.get_transform("canvas", "scene")

def _under_mouse_index(self, pos: Sequence[float]) -> int | None:
"""Returns an int in [0, 3], -1, or None.

If an int i, means that the handle at self._positions[i] is at pos.
If -1, means that the mouse is within the rectangle.
If None, there is no handle at pos.
"""
# check if the mouse is over a handle
rad2 = (self._handle_size / 2) ** 2
for i, p in enumerate(self._handle_data):
if (p[0] - pos[0]) ** 2 + (p[1] - pos[1]) ** 2 <= rad2:
return i
# check if the mouse is within the rectangle
left, bottom = self._handle_data[0]
right, top = self._handle_data[2]
return -1 if left <= pos[0] <= right and bottom <= pos[1] <= top else None
Loading
Loading