-
Notifications
You must be signed in to change notification settings - Fork 8
feat: added GridFromPolygon #224
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
Conversation
Codecov ReportAttention: Patch coverage is
❌ Your patch check has failed because the patch coverage (25.61%) is below the target coverage (85.00%). You can increase the patch coverage or adjust the target coverage.
Additional details and impacted files@@ Coverage Diff @@
## main #224 +/- ##
==========================================
- Coverage 93.96% 88.88% -5.09%
==========================================
Files 22 22
Lines 1509 1629 +120
==========================================
+ Hits 1418 1448 +30
- Misses 91 181 +90 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
hey @rlokkie, just wanted to give you a little exciting peek of how this could end up looking (interactively) in the stage explorer widget: Screen.Recording.2025-05-12.at.6.15.34.PM.movOn some consideration, I think i'm find with depending on shapely. It only depends on numpy, so that's good... it could still be an optional/extra dependency and we just don't iterate polygon plans without it. This was the code I used to find the stage coordinates that need to be visited to fill the polygon: def plan_polygon_tiling(
poly_xy: Sequence[tuple[float, float]],
fov: tuple[float, float],
overlap: float = 0.2,
order: Literal["serpentine", "raster"] = "serpentine",
) -> list[tuple[float, float]]:
"""Compute an ordered list of (x, y) stage positions that cover the polygonal ROI."""
try:
from shapely.geometry import Polygon, box
from shapely.prepared import prep
except ImportError:
raise ImportError(
"plan_polygon_tiling requires shapely. "
"Please install it with 'pip install shapely'."
) from None
poly = Polygon(poly_xy)
if not poly.is_valid:
raise ValueError("Invalid or self-intersecting polygon.")
prepared_poly = prep(poly)
# Compute grid spacing and half-extents
w, h = fov
dx = w * (1 - overlap)
dy = h * (1 - overlap)
half_w, half_h = w / 2, h / 2
# Expand bounds to ensure full coverage
minx, miny, maxx, maxy = poly.bounds
minx -= half_w
miny -= half_h
maxx += half_w
maxy += half_h
# Determine grid dimensions
n_cols = int(np.ceil((maxx - minx) / dx))
n_rows = int(np.ceil((maxy - miny) / dy))
# Generate center coordinates
xs = minx + (np.arange(n_cols) + 0.5) * dx
ys = miny + (np.arange(n_rows) + 0.5) * dy
# Generate grid positions
positions: list[tuple[float, float]] = []
for row_idx, y in enumerate(ys):
row_xs = (
xs
if order == "raster" or (order == "serpentine" and row_idx % 2 == 0)
else xs[::-1]
)
for x in row_xs:
tile = box(x - half_w, y - half_h, x + half_w, y + half_h)
if prepared_poly.intersects(tile):
positions.append((x, y))
return positions |
woah, that looks so smooth! It's somewhat of an edge case to create grids this way anyway, so having it as an optional dependency makes sense to me. Since the tiles can interactively be reviewed, I don't think a convex hull is necessary. Would a right-click option to set the fov for a polygon in the explorer be useful so you can visualize the tiles for different objectives? Vertices will be XY in the explorer, right? |
I agree, or at least, it could be a feature we add later.
👍
yeah: probably. most of the world outside of python seems to go with that 😂 including micro-manager. I hadn't been paying close attention but you're probably right. by the way, the temporary object I was using in pymmcore-plus/pymmcore-widgets#431 is here ... and looks like this: from shapely import Polygon, box, prepared
class GridFromPolygon(useq._grid._GridPlan[useq.AbsolutePosition]):
vertices: Annotated[
list[tuple[float, float]],
Field(
min_length=3,
description="List of points that define the polygon",
frozen=True,
),
]
def num_positions(self) -> int:
"""Return the number of positions in the grid."""
if self.fov_width is None or self.fov_height is None:
raise ValueError("fov_width and fov_height must be set")
return len(
self._cached_tiles(
fov=(self.fov_width, self.fov_height), overlap=self.overlap
)
)
def iter_grid_positions(
self,
fov_width: float | None = None,
fov_height: float | None = None,
*,
order: useq.OrderMode | None = None,
) -> Iterator[useq.AbsolutePosition]:
"""Iterate over all grid positions, given a field of view size."""
try:
pos = self._cached_tiles(
fov=(
fov_width or self.fov_width or 1,
fov_height or self.fov_height or 1,
),
overlap=self.overlap,
order=order,
)
except ValueError:
pos = []
for x, y in pos:
yield useq.AbsolutePosition(x=x, y=y)
@cached_property
def poly(self) -> Polygon:
"""Return the polygon vertices as a list of (x, y) tuples."""
return Polygon(self.vertices)
@cached_property
def prepared_poly(self) -> prepared.PreparedGeometry:
"""Return the prepared polygon for faster intersection tests."""
return prepared.prep(self.poly)
_poly_cache: dict[tuple, list[tuple[float, float]]] = PrivateAttr(
default_factory=dict
)
def _cached_tiles(
self,
*,
fov: tuple[float, float],
overlap: tuple[float, float],
order: useq.OrderMode | None = None,
) -> list[tuple[float, float]]:
"""Compute an ordered list of (x, y) stage positions that cover the ROI."""
# Compute grid spacing and half-extents
mode = useq.OrderMode(order) if order is not None else self.mode
key = (fov, overlap, mode)
if key not in self._poly_cache:
w, h = fov
dx = w * (1 - overlap[0])
dy = h * (1 - overlap[1])
half_w, half_h = w / 2, h / 2
# Expand bounds to ensure full coverage
minx, miny, maxx, maxy = self.poly.bounds
minx -= half_w
miny -= half_h
maxx += half_w
maxy += half_h
# Determine grid dimensions
n_cols = int(np.ceil((maxx - minx) / dx))
n_rows = int(np.ceil((maxy - miny) / dy))
# Generate grid positions
positions: list[tuple[float, float]] = []
prepared_poly = self.prepared_poly
for r, c in mode.generate_indices(n_rows, n_cols):
x = c + minx + (c + 0.5) * dx
y = r + miny + (r + 0.5) * dy
tile = box(x - half_w, y - half_h, x + half_w, y + half_h)
if prepared_poly.intersects(tile):
positions.append((x, y))
self._poly_cache[key] = positions
return self._poly_cache[key] it was quick and dirty, so definitely could use a more critical look, but is more or less the minimum functionality we'll need covered |
Looks good to me, but the GridFromEdges grid plan and GridFromPolygon seem to do the same thing just slightly different now.
and
I think this; (y = r + y0 + (r + 0.5) * dy,) direction is correct when origin is top left. But without the + 0.5 (so; y = y0 + r * dy,), this does require the offset to be flipped in GridFromEdges
|
that explains why my polygons were acquiring from bottom to top! 😂 |
in any case, I think the order mode needs a good reviewing anyway. I think the default mode should now be "minimal path"... since concave polygons can take some rather wonky paths with the standard grid modes (for example, an off-axis C-shaped polygon). |
Agree a minimal path would look less wonky. But implementing a minimal/Hamiltonian path may lead to more unpredictable steps, in favour of a relatively small increase in time. Will try to see if I can add it either way, but I'm a little hesitant on having it as a default. |
…d 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.
i see some new activity here. Let me know when you want a final review on this apologies!! I didn't mean to hit close, I meant to run the tests |
…nlarged 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.
ah no worries! |
Thanks for your work here @rlokkie! This is very close to what we need. ❤️ We've got a moment to work on this right now, so I'm going to just merge this and follow up with some additional PRs on top of this (we will likely change the field name thanks for the great work! |
Great! Hope people will use it! |
early-WIP (just to get it out there)* for GridFromPolygon, this still requires shapely for polygon offsetting.
Also somewhat sloppy, but a multi-position grid_plan can be constructed from a polygon.
grid_plan = GridFromPolygon(polygon= list_of_vertices,convex_hull=True ,overlap=10,fov_height=100,fov_width=100, mode = 'row_wise_snake')
*edit:
Will create a new pr when I create a pure model without external library (will also include a def model_post_init() to clean up the tooltip).