|
16 | 16 |
|
17 | 17 | import numpy as np
|
18 | 18 | 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 |
20 | 20 | from typing_extensions import Self, TypeAlias
|
21 | 21 |
|
22 | 22 | from useq._point_visiting import OrderMode, TraversalOrder
|
|
27 | 27 | _MultiPointPlan,
|
28 | 28 | )
|
29 | 29 |
|
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 | + |
32 | 41 |
|
| 42 | +if TYPE_CHECKING: |
33 | 43 | PointGenerator: TypeAlias = Callable[
|
34 | 44 | [np.random.RandomState, int, float, float], Iterable[tuple[float, float]]
|
35 | 45 | ]
|
@@ -111,8 +121,9 @@ def num_positions(self) -> int:
|
111 | 121 | Note: For GridFromEdges and GridWidthHeight, this will depend on field of view
|
112 | 122 | size. If no field of view size is provided, the number of positions will be 1.
|
113 | 123 | """
|
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] |
116 | 127 | ):
|
117 | 128 | raise ValueError(
|
118 | 129 | "Retrieving the number of positions in a GridFromEdges or "
|
@@ -236,7 +247,7 @@ def _offset_y(self, dy: float) -> float:
|
236 | 247 | # start the _centre_ half a FOV down from the top edge
|
237 | 248 | return max(self.top, self.bottom) - (self.fov_height or 0) / 2
|
238 | 249 |
|
239 |
| - def plot(self, *, show: bool = True) -> Axes: |
| 250 | + def plot(self, *, show: bool = True) -> axes: |
240 | 251 | """Plot the positions in the plan."""
|
241 | 252 | from useq._plot import plot_points
|
242 | 253 |
|
@@ -368,6 +379,190 @@ def _offset_y(self, dy: float) -> float:
|
368 | 379 | )
|
369 | 380 |
|
370 | 381 |
|
| 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 | + |
371 | 566 | # ------------------------ RANDOM ------------------------
|
372 | 567 |
|
373 | 568 |
|
@@ -540,5 +735,5 @@ def _random_points_in_rectangle(
|
540 | 735 | RelativeMultiPointPlan = Union[
|
541 | 736 | GridRowsColumns, GridWidthHeight, RandomPoints, RelativePosition
|
542 | 737 | ]
|
543 |
| -AbsoluteMultiPointPlan = Union[GridFromEdges] |
| 738 | +AbsoluteMultiPointPlan = Union[GridFromEdges, GridFromPolygon] |
544 | 739 | MultiPointPlan = Union[AbsoluteMultiPointPlan, RelativeMultiPointPlan]
|
0 commit comments