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: add minimal Points plan view #316

Merged
merged 48 commits into from
Jul 10, 2024
Merged
Show file tree
Hide file tree
Changes from 39 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
8c5cf47
feat: multi point plan useq widgets
fdrgsp Jul 7, 2024
8247b49
pull out pyproject
tlambert03 Jul 7, 2024
a249d64
pyproject
fdrgsp Jul 7, 2024
1cfd9c8
cleanup grid row
tlambert03 Jul 7, 2024
0256cca
cleanup
tlambert03 Jul 7, 2024
f6544a7
style(pre-commit.ci): auto fixes [...]
pre-commit-ci[bot] Jul 7, 2024
252cb2b
tests
tlambert03 Jul 7, 2024
f36d29d
lint
tlambert03 Jul 7, 2024
9ed073e
bump
tlambert03 Jul 7, 2024
863e2f4
fov-widget
fdrgsp Jul 7, 2024
c0965e6
wip
tlambert03 Jul 7, 2024
99c037a
Merge branch 'main' into points-plan-selector
tlambert03 Jul 7, 2024
96d20d3
finish
tlambert03 Jul 7, 2024
2a04aa2
rm x
tlambert03 Jul 7, 2024
ed7b0f4
remove hcs
tlambert03 Jul 7, 2024
fc3f7f2
test
tlambert03 Jul 7, 2024
cae38e8
lint
tlambert03 Jul 7, 2024
a05ef2f
more test
tlambert03 Jul 7, 2024
a3f7023
style
tlambert03 Jul 7, 2024
60e288a
typing
tlambert03 Jul 7, 2024
4937860
rename
tlambert03 Jul 7, 2024
53ba0d8
lint
tlambert03 Jul 7, 2024
2c33634
fix again you dummy
tlambert03 Jul 7, 2024
9a1ba8d
fov-widget-selector
fdrgsp Jul 7, 2024
e346dbc
wip
tlambert03 Jul 7, 2024
596ae15
demo
tlambert03 Jul 8, 2024
2f81806
init files
tlambert03 Jul 8, 2024
a3d81fa
Merge branch 'main' into points-plan-view
tlambert03 Jul 8, 2024
6ebf75e
fix import
tlambert03 Jul 8, 2024
04673ce
chore: Update FOVSelectorWidget to disallow overlap in random points
tlambert03 Jul 8, 2024
09fddaa
minor
tlambert03 Jul 8, 2024
84bb4c2
add connecting line
tlambert03 Jul 8, 2024
052b8ff
Merge branch 'main' into points-plan-view
tlambert03 Jul 8, 2024
2c51f2a
click on point
tlambert03 Jul 9, 2024
5f06786
style(pre-commit.ci): auto fixes [...]
pre-commit-ci[bot] Jul 9, 2024
aed76e0
lint
tlambert03 Jul 9, 2024
bcc0651
Merge branch 'points-plan-view' of https://github.com/tlambert03/pymm…
tlambert03 Jul 9, 2024
93c3662
fixup
tlambert03 Jul 9, 2024
7382ad8
deal with None
tlambert03 Jul 9, 2024
06846ba
cleanup
tlambert03 Jul 10, 2024
dc3707a
fix test
tlambert03 Jul 10, 2024
e76fd15
more fov fixes
tlambert03 Jul 10, 2024
5e9b6d6
move to useq
tlambert03 Jul 10, 2024
480cbdd
add tests
tlambert03 Jul 10, 2024
7e8a244
tighten
tlambert03 Jul 10, 2024
2718bdb
fix test
tlambert03 Jul 10, 2024
6d20fed
fix deprecation
tlambert03 Jul 10, 2024
e0336e3
docs
tlambert03 Jul 10, 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
13 changes: 13 additions & 0 deletions examples/hcs_temp/fov_selector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from qtpy.QtWidgets import QApplication
from useq import RandomPoints

from pymmcore_widgets.hcs._fov_widget._fov_widget import FOVSelectorWidget

app = QApplication([])

fs = FOVSelectorWidget(
RandomPoints(num_points=80, allow_overlap=False, fov_height=350, fov_width=400)
)
fs.show()

app.exec()
1 change: 1 addition & 0 deletions src/pymmcore_widgets/hcs/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""High content screening widgets for PyMMCore."""
Empty file.
58 changes: 58 additions & 0 deletions src/pymmcore_widgets/hcs/_fov_widget/_fov_widget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from __future__ import annotations

import useq
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QHBoxLayout, QWidget

from pymmcore_widgets.useq_widgets.points_plans import RelativePointPlanSelector

from ._well_graphics_view import WellView


class FOVSelectorWidget(QWidget):
"""Widget to select the FOVVs per well of the plate."""

valueChanged = Signal(object)

def __init__(
self,
plan: useq.RelativeMultiPointPlan | None = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent=parent)

self.selector = RelativePointPlanSelector()
# graphics scene to draw the well and the fovs
self.well_view = WellView()

# main
layout = QHBoxLayout(self)
layout.addWidget(self.selector)
layout.addWidget(self.well_view)

# connect
self.selector.valueChanged.connect(self._on_selector_value_changed)
self.well_view.maxPointsDetected.connect(self._on_view_max_points_detected)
self.well_view.positionClicked.connect(self._on_view_position_clicked)

if plan is not None:
self.setValue(plan)

def value(self) -> useq.RelativeMultiPointPlan:
return self.selector.value()

def setValue(self, plan: useq.RelativeMultiPointPlan) -> None:
self.selector.setValue(plan)

def _on_selector_value_changed(self, value: useq.RelativeMultiPointPlan) -> None:
self.well_view.setPointsPlan(value)
self.valueChanged.emit(value)

def _on_view_max_points_detected(self, value: int) -> None:
self.selector.random_points_wdg.num_points.setValue(value)

def _on_view_position_clicked(
self, index: int, position: useq.RelativePosition
) -> None:
if self.selector.active_plan_type is useq.RandomPoints:
self.selector.random_points_wdg.start_at = position.replace(name=None)
tlambert03 marked this conversation as resolved.
Show resolved Hide resolved
187 changes: 187 additions & 0 deletions src/pymmcore_widgets/hcs/_fov_widget/_well_graphics_view.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
from __future__ import annotations

import warnings
from typing import TYPE_CHECKING

import useq
from qtpy.QtCore import QRectF, QSize, Qt, Signal
from qtpy.QtGui import QColor, QPainter, QPen
from qtpy.QtWidgets import QGraphicsItem, QGraphicsScene, QWidget
from useq import Shape

from pymmcore_widgets.hcs._util import ResizingGraphicsView

if TYPE_CHECKING:
from PyQt6.QtGui import QMouseEvent

DATA_POSITION = 1
DATA_POSITION_INDEX = 2


class WellView(ResizingGraphicsView):
"""Graphics view to draw a well and the FOVs."""

maxPointsDetected = Signal(int)
positionClicked = Signal(int, object)

def __init__(self, parent: QWidget | None = None) -> None:
self._scene = QGraphicsScene()

super().__init__(self._scene, parent)
self.setStyleSheet("background:grey; border-radius: 5px;")
self.setRenderHints(
QPainter.RenderHint.Antialiasing | QPainter.RenderHint.SmoothPixmapTransform
)

# the scene coordinates are all real-world coordinates, in µm
# with the origin at the center of the view (0, 0)
self._well_width_um: float | None = 6000
self._well_height_um: float | None = 6000
self._fov_width_um: float = 400
self._fov_height_um: float = 340
self._is_circular: bool = False

# the item that draws the outline of the entire well area
self._outline_item: QGraphicsItem | None = None
# all of the rectangles representing the FOVs
self._fov_items: list[QGraphicsItem] = []

self.setMinimumSize(250, 250)

def sizeHint(self) -> QSize:
return QSize(500, 500)

def setPointsPlan(self, plan: useq.RelativeMultiPointPlan) -> None:
"""Set the plan to use to draw the FOVs."""
plandict = plan.model_dump(exclude_none=True)
# use our fov values if the plan doesn't have them
plandict.setdefault("fov_width", self._fov_width_um)
plandict.setdefault("fov_height", self._fov_height_um)
plan = plan.model_construct(**plandict)

self._fov_width_um = plan.fov_width
self._fov_height_um = plan.fov_height
if isinstance(plan, useq.RandomPoints):
self._is_circular = plan.shape == Shape.ELLIPSE

# WELL OUTLINE
self._draw_outline()

# DRAW FOVS
self._draw_fovs(plan)

def mousePressEvent(self, event: QMouseEvent | None) -> None:
if event is None:
return
scene_pos = self.mapToScene(event.pos())
items = self.scene().items(scene_pos)
for item in items:
if idx := item.data(DATA_POSITION_INDEX):
self.positionClicked.emit(idx, item.data(DATA_POSITION))
break

def setWellSize(self, width_mm: float | None, height_mm: float | None) -> None:
"""Set the well size width and height in mm."""
self._well_width_um = (width_mm * 1000) if width_mm else None
self._well_height_um = (height_mm * 1000) if height_mm else None

def _well_rect(self) -> QRectF:
"""Return the QRectF of the well area."""
if not self._well_width_um or not self._well_height_um:
return QRectF()
return QRectF(
-self._well_width_um / 2,
-self._well_height_um / 2,
self._well_width_um,
self._well_height_um,
)

def _draw_outline(self) -> None:
"""Draw the outline of the well area."""
if self._outline_item:
self._scene.removeItem(self._outline_item)
if (rect := self._well_rect()).isNull():
return

pen = QPen(QColor(Qt.GlobalColor.green))
pen.setWidth(self._scaled_pen_size())
if self._is_circular:
self._outline_item = self._scene.addEllipse(rect, pen=pen)
else:
self._outline_item = self._scene.addRect(rect, pen=pen)
self._resize_to_fit()

def _resize_to_fit(self) -> None:
self.setSceneRect(self._scene.itemsBoundingRect())
self.resizeEvent(None)

def _draw_fovs(self, plan: useq.RelativeMultiPointPlan) -> None:
"""Draw the fovs in the scene as rectangles."""
# delete existing FOVs
while self._fov_items:
self._scene.removeItem(self._fov_items.pop())

half_fov_width = self._fov_width_um / 2
half_fov_height = self._fov_height_um / 2

# constrain random points to our own well size, regardless of the plan settings
if isinstance(plan, useq.RandomPoints):
kwargs = {}
ww = self._well_width_um or (self._fov_width_um * 25)
kwargs["max_width"] = ww - half_fov_width * 1.4
wh = self._well_height_um or (self._fov_height_um * 25)
kwargs["max_height"] = wh - half_fov_height * 1.4
plan = plan.replace(**kwargs)

pen = QPen(Qt.GlobalColor.white)
pen.setWidth(self._scaled_pen_size())
line_pen = QPen(QColor(0, 0, 0, 100))
line_pen.setWidth(int(self._scaled_pen_size() // 1.5))

# iterate over the plan greedily, catching any warnings
# and then alert the model if we didn't get all the points
# XXX: I'm not sure about this pattern... feels like the model should be
# able to handle this itself, but this is perhaps the only place we actually
# iterate over the plan
with warnings.catch_warnings():
warnings.simplefilter("ignore")
points = list(plan)
if len(points) < getattr(plan, "num_points", 0):
self.maxPointsDetected.emit(len(points))
tlambert03 marked this conversation as resolved.
Show resolved Hide resolved

# draw the FOVs, and a connecting line
last_p: useq.RelativePosition | None = None
for i, pos in enumerate(points):
pos.y *= -1 # invert y for screen coordinates
if i == 0:
pen.setColor(QColor(Qt.GlobalColor.black))
else:
pen.setColor(QColor(Qt.GlobalColor.white))
item = self._scene.addRect(
pos.x - half_fov_width,
pos.y - half_fov_height,
self._fov_width_um,
self._fov_height_um,
pen,
)
if item:
item.setData(DATA_POSITION, pos)
item.setData(DATA_POSITION_INDEX, i)
item.setZValue(100 if i == 0 else 0)
self._fov_items.append(item)
# draw a line from the last point to this one
if i > 0 and last_p:
self._fov_items.append(
self._scene.addLine(last_p.x, last_p.y, pos.x, pos.y, line_pen)
)
last_p = pos

self._resize_to_fit()

def _scaled_pen_size(self) -> int:
# pick a pen size appropriate for the scene scale
# we might also want to scale this based on the sceneRect...
# and it's possible this needs to be rescaled on resize
if self._well_width_um:
return int(self._well_width_um / 150)
return max(61, int(self.sceneRect().width() / 150))
27 changes: 27 additions & 0 deletions src/pymmcore_widgets/hcs/_util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from __future__ import annotations

from typing import TYPE_CHECKING

from qtpy.QtCore import QMarginsF, Qt
from qtpy.QtWidgets import QGraphicsScene, QGraphicsView, QWidget

if TYPE_CHECKING:
from qtpy.QtGui import QResizeEvent


class ResizingGraphicsView(QGraphicsView):
"""A QGraphicsView that resizes the scene to fit the view."""

def __init__(self, scene: QGraphicsScene, parent: QWidget | None = None) -> None:
super().__init__(scene, parent)
self.padding = 0.05 # fraction of the bounding rect

def resizeEvent(self, event: QResizeEvent) -> None:
if not (scene := self.scene()):
return
rect = scene.itemsBoundingRect()
xmargin = rect.width() * self.padding
ymargin = rect.height() * self.padding
margins = QMarginsF(xmargin, ymargin, xmargin, ymargin)
self.fitInView(rect.marginsAdded(margins), Qt.AspectRatioMode.KeepAspectRatio)
super().resizeEvent(event)
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def __init__(self, parent: QWidget | None = None) -> None:
# overlap along y
self.overlap_y = QDoubleSpinBox()
self.overlap_y.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.overlap_x.setRange(-10000, 100)
self.overlap_y.setRange(-10000, 100)
# order combo
self.mode = QComboBox()
self.mode.addItems([mode.value for mode in OrderMode])
Expand Down
Loading
Loading