Skip to content

Commit ed42cec

Browse files
rlokkiepre-commit-ci[bot]tlambert03
authored
feat: added GridFromPolygon (#224)
* added GridFromPolygon * style(pre-commit.ci): auto fixes [...] * shapely for all logic, and refactored pydantic bit * style(pre-commit.ci): auto fixes [...] * feat: brought tile formation in line with other gridplans, and updated plotting * style(pre-commit.ci): auto fixes [...] * refactored docstrings * style(pre-commit.ci): auto fixes [...] * moved polygon manipulation to initialization, added plotting offsetted polygon Moved offset and convex hull before bounding box creation of prepared polygon. Offset polygon is now used for plotting. Some skipped tiles seem to be present when offsetting, different buffer style may fix that. Not happy yet with the coverage of the exterior line of the polygon, may be because of the bounding box is under/over-influenced by fov-size. * style(pre-commit.ci): auto fixes [...] * Added docstrings, removed some non functional pydantic annotations, enlarged initial bounding box enlarged the initial bounding box slightly by fov, this ensures to-be-checked tiles will also be created on the extreme edges of the polygon. * Import PrivateAttr to hide some attributes in the signature gridfrompolygon * Fix private attributes * style(pre-commit.ci): auto fixes [...] --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Talley Lambert <[email protected]>
1 parent 1f5ba81 commit ed42cec

File tree

3 files changed

+213
-8
lines changed

3 files changed

+213
-8
lines changed

src/useq/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from useq._channel import Channel
88
from useq._grid import (
99
GridFromEdges,
10+
GridFromPolygon,
1011
GridRowsColumns,
1112
GridWidthHeight,
1213
MultiPointPlan,
@@ -57,6 +58,7 @@
5758
"CustomAction",
5859
"EventChannel",
5960
"GridFromEdges",
61+
"GridFromPolygon",
6062
"GridRelative",
6163
"GridRowsColumns",
6264
"GridWidthHeight",

src/useq/_grid.py

Lines changed: 202 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import numpy as np
1818
from annotated_types import Ge, Gt
19-
from pydantic import Field, field_validator, model_validator
19+
from pydantic import Field, PrivateAttr, field_validator, model_validator
2020
from typing_extensions import Self, TypeAlias
2121

2222
from useq._point_visiting import OrderMode, TraversalOrder
@@ -27,9 +27,19 @@
2727
_MultiPointPlan,
2828
)
2929

30-
if TYPE_CHECKING:
31-
from matplotlib.axes import Axes
30+
try:
31+
from shapely.geometry import Polygon, box
32+
from shapely.prepared import prep
33+
34+
shapely_installed = True
35+
except ImportError:
36+
raise ImportError(
37+
"plan_polygon_tiling requires shapely. "
38+
"Please install it with 'pip install shapely'."
39+
) from None
40+
3241

42+
if TYPE_CHECKING:
3343
PointGenerator: TypeAlias = Callable[
3444
[np.random.RandomState, int, float, float], Iterable[tuple[float, float]]
3545
]
@@ -111,8 +121,9 @@ def num_positions(self) -> int:
111121
Note: For GridFromEdges and GridWidthHeight, this will depend on field of view
112122
size. If no field of view size is provided, the number of positions will be 1.
113123
"""
114-
if isinstance(self, (GridFromEdges, GridWidthHeight)) and (
115-
self.fov_width is None or self.fov_height is None
124+
if isinstance(self, (GridFromEdges, GridWidthHeight, GridFromPolygon)) and (
125+
# type ignore is because mypy thinks self is Never here...
126+
self.fov_width is None or self.fov_height is None # type: ignore [attr-defined]
116127
):
117128
raise ValueError(
118129
"Retrieving the number of positions in a GridFromEdges or "
@@ -236,7 +247,7 @@ def _offset_y(self, dy: float) -> float:
236247
# start the _centre_ half a FOV down from the top edge
237248
return max(self.top, self.bottom) - (self.fov_height or 0) / 2
238249

239-
def plot(self, *, show: bool = True) -> Axes:
250+
def plot(self, *, show: bool = True) -> axes:
240251
"""Plot the positions in the plan."""
241252
from useq._plot import plot_points
242253

@@ -368,6 +379,190 @@ def _offset_y(self, dy: float) -> float:
368379
)
369380

370381

382+
class GridFromPolygon(_GridPlan[AbsolutePosition]):
383+
"""Yield absolute stage positions to cover an area defined by a polygon.
384+
385+
Tiles are created by intersecting the polygon's-bounding-box-grid with
386+
the polygon. Additionally the convex hull, and/or a buffered
387+
polygon can be created to generate
388+
tiles over a larger area surrounding the initial polygon.
389+
390+
Attributes
391+
----------
392+
polygon : list[tuple[float,float]]
393+
list of minimum 3 vertices of a polygon in XY.
394+
'[[x,y],[x,y],[x,y].....]
395+
convex hull : Optional[boolean]
396+
True to create a convex hull from the polygon
397+
offset : Optional[float]
398+
Offsets(dilates) polygon prior to polygon-tile-intersection to
399+
improve coverage of tiles.
400+
overlap : float | tuple[float, float]
401+
Overlap between grid positions in percent. If a single value is provided, it is
402+
used for both x and y. If a tuple is provided, the first value is used
403+
for x and the second for y.
404+
mode : OrderMode
405+
Define the ways of ordering the grid positions. Options are
406+
row_wise, column_wise, row_wise_snake, column_wise_snake and spiral.
407+
By default, row_wise_snake.
408+
fov_width : Optional[float]
409+
Width of the field of view in microns. If not provided, acquisition engines
410+
should use current width of the FOV based on the current objective and camera.
411+
Engines MAY override this even if provided.
412+
fov_height : Optional[float]
413+
Height of the field of view in microns. If not provided, acquisition engines
414+
should use current height of the FOV based on the current objective and camera.
415+
Engines MAY override this even if provided.
416+
#TODO Add TraversalOrder as an option after polygon tile creation.
417+
"""
418+
419+
polygon: Annotated[
420+
list[tuple[float, float]],
421+
Field(
422+
...,
423+
min_length=3,
424+
description="List of points that define the polygon, "
425+
"must be at least 3 vertices",
426+
frozen=True,
427+
),
428+
]
429+
convex_hull: Annotated[
430+
Optional[bool],
431+
Field(
432+
False,
433+
description="If True, the convex hull of the polygon will be used.",
434+
),
435+
]
436+
offset: Annotated[
437+
Optional[float],
438+
Field(
439+
None,
440+
frozen=True,
441+
description="Offsets the polygon in all directions to "
442+
"improve tile coverage.",
443+
),
444+
]
445+
_prepared_poly: Annotated[Optional[object], Field(...)] = PrivateAttr(None)
446+
_top_bound: Annotated[Optional[float], Field(..., init=False)] = PrivateAttr(None)
447+
_left_bound: Annotated[Optional[float], Field(..., init=False)] = PrivateAttr(None)
448+
_bottom_bound: Annotated[Optional[float], Field(..., init=False)] = PrivateAttr(
449+
None
450+
)
451+
_right_bound: Annotated[Optional[float], Field(..., init=False)] = PrivateAttr(None)
452+
_plot_poly: Annotated[
453+
Optional[object],
454+
Field(..., description="An unprepared polygon for plotting purposes only"),
455+
] = PrivateAttr(None)
456+
457+
def model_post_init(self, __context) -> None:
458+
poly = Polygon(self.polygon)
459+
if not poly.is_valid:
460+
raise ValueError("Invalid or self-intersecting polygon.")
461+
# Buffers the polygon with a given diistance
462+
if self.offset is not None:
463+
poly = self._offset_polygon(Polygon(self.polygon), self.offset)
464+
# Creates a convex hull of the input polygon
465+
if self.convex_hull:
466+
poly = poly.convex_hull
467+
self._plot_poly = poly
468+
self._prepared_poly = prep(
469+
poly
470+
) # operations on prepared polygon are more efficient.
471+
472+
self._left_bound, self._bottom_bound, self._right_bound, self._top_bound = (
473+
poly.bounds
474+
)
475+
# Enlarge the Bbox slightly based on fov dimensions
476+
self._top_bound += self.fov_height / 4
477+
self._left_bound -= self.fov_width / 4
478+
self._bottom_bound -= self.fov_height / 4
479+
self._right_bound += self.fov_width / 4
480+
481+
def _offset_polygon(self, vertices, offset) -> list:
482+
"""Offsets/buffers the polygon with a given distance and joins when overlapping."""
483+
geom = vertices
484+
vertices = geom.buffer(distance=offset, cap_style="round", join_style="round")
485+
return vertices
486+
487+
def _intersect_raster_with_polygon(self) -> Iterator[PositionT]:
488+
"""Loops through bounding box grid positions and yields/retains the position
489+
if the tile intersects with the polygon.
490+
"""
491+
grid_from_bounding_box = self.iter_grid_positions()
492+
for position in list(grid_from_bounding_box):
493+
tile = box(
494+
position.x - self.fov_width / 2,
495+
position.y - self.fov_height / 2,
496+
position.x + self.fov_width / 2,
497+
position.y + self.fov_height / 2,
498+
)
499+
if self._prepared_poly.intersects(tile):
500+
yield position
501+
502+
@property
503+
def is_relative(self) -> bool:
504+
return False
505+
506+
def _nrows(self, dy: float) -> int:
507+
if self.fov_height is None:
508+
total_height = abs(self._top_bound - self._bottom_bound) + dy
509+
return math.ceil(total_height / dy)
510+
511+
span = abs(self._top_bound - self._bottom_bound)
512+
# if the span is smaller than one FOV, just one row
513+
if span <= self.fov_height:
514+
return 1
515+
# otherwise: one FOV plus (nrows-1)⋅dy must cover span
516+
return math.ceil((span - self.fov_height) / dy) + 1
517+
518+
def _ncolumns(self, dx: float) -> int:
519+
if self.fov_width is None:
520+
total_width = abs(self._right_bound - self._left_bound) + dx
521+
return math.ceil(total_width / dx)
522+
523+
span = abs(self._right_bound - self._left_bound)
524+
if span <= self.fov_width:
525+
return 1
526+
return math.ceil((span - self.fov_width) / dx) + 1
527+
528+
def _offset_x(self, dx: float) -> float:
529+
return min(self._left_bound, self._right_bound) + (self.fov_width or 0) / 2
530+
531+
def _offset_y(self, dy: float) -> float:
532+
return max(self._top_bound, self._bottom_bound) - (self.fov_height or 0) / 2
533+
534+
def plot(self, *, show: bool = True) -> Axes:
535+
"""Plot the positions in the plan."""
536+
from useq._plot import plot_points
537+
538+
if self.fov_width is not None and self.fov_height is not None:
539+
rect = (self.fov_width, self.fov_height)
540+
else:
541+
rect = None
542+
543+
return plot_points(
544+
self,
545+
rect_size=rect,
546+
polygon=self._plot_poly.exterior.coords, # exterior creates a linearRing from the polygon, coords gets the vertices
547+
bounding_box=(
548+
self._left_bound,
549+
self._top_bound,
550+
self._right_bound,
551+
self._bottom_bound,
552+
),
553+
show=show,
554+
)
555+
556+
def num_positions(self) -> int:
557+
"""Return the number of positions within the polygon."""
558+
if self.fov_width is None or self.fov_height is None:
559+
raise ValueError("fov_width and fov_height must be set")
560+
return sum(1 for _ in self._intersect_raster_with_polygon())
561+
562+
def __iter__(self) -> Iterator[PositionT]:
563+
yield from self._intersect_raster_with_polygon()
564+
565+
371566
# ------------------------ RANDOM ------------------------
372567

373568

@@ -540,5 +735,5 @@ def _random_points_in_rectangle(
540735
RelativeMultiPointPlan = Union[
541736
GridRowsColumns, GridWidthHeight, RandomPoints, RelativePosition
542737
]
543-
AbsoluteMultiPointPlan = Union[GridFromEdges]
738+
AbsoluteMultiPointPlan = Union[GridFromEdges, GridFromPolygon]
544739
MultiPointPlan = Union[AbsoluteMultiPointPlan, RelativeMultiPointPlan]

src/useq/_plot.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ def plot_points(
2424
*,
2525
rect_size: tuple[float, float] | None = None,
2626
bounding_box: tuple[float, float, float, float] | None = None,
27+
polygon: tuple[float, float] | None = None,
2728
ax: Axes | None = None,
2829
show: bool = True,
2930
) -> Axes:
@@ -80,6 +81,13 @@ def plot_points(
8081
ax.set_xlim(min(x) - half_width, max(x) + half_width)
8182
ax.set_ylim(min(y) - half_height, max(y) + half_height)
8283

84+
if polygon is not None:
85+
y_poly, x_poly, *_ = zip(*list(polygon))
86+
y_poly += (y_poly[0],)
87+
x_poly += (x_poly[0],)
88+
ax.scatter(y_poly, x_poly, color="magenta")
89+
ax.plot(y_poly, x_poly, color="yellow")
90+
8391
if bounding_box is not None:
8492
# draw a thicker dashed line around the bounding box
8593
x0, y0, x1, y1 = bounding_box
@@ -94,7 +102,7 @@ def plot_points(
94102
# ensure the bounding box is visible
95103
ax.set_xlim(min(x0, x1) - 10, max(x0, x1) + 10)
96104
ax.set_ylim(min(y0, y1) - 10, max(y0, y1) + 10)
97-
105+
# ax.invert_yaxis()
98106
ax.axis("equal")
99107
if show:
100108
plt.show()

0 commit comments

Comments
 (0)