Skip to content

Conversation

rlokkie
Copy link
Contributor

@rlokkie rlokkie commented May 8, 2025

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).

Copy link

codecov bot commented May 9, 2025

Codecov Report

Attention: Patch coverage is 25.61983% with 90 lines in your changes missing coverage. Please review.

Project coverage is 88.88%. Comparing base (e87673b) to head (9e08fc4).

Files with missing lines Patch % Lines
src/useq/_grid.py 25.61% 90 Missing ⚠️

❌ 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.

❗ There is a different number of reports uploaded between BASE (e87673b) and HEAD (9e08fc4). Click for more details.

HEAD has 1 upload less than BASE
Flag BASE (e87673b) HEAD (9e08fc4)
2 1
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tlambert03
Copy link
Member

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.mov

On 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

@rlokkie
Copy link
Contributor Author

rlokkie commented May 13, 2025

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.
Maybe a 'right-click option' in the stage explorer widget to construct one quickly could be a quality of life feature for some.

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?

yx and shapely don't seem to agree:
afbeelding

@tlambert03
Copy link
Member

I don't think a convex hull is necessary.

I agree, or at least, it could be a feature we add later.

Maybe a 'right-click option' in the stage explorer widget to construct one quickly could be a quality of life feature for some.

👍

Vertices will be XY in the explorer, right?

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

@rlokkie
Copy link
Contributor Author

rlokkie commented May 13, 2025

Looks good to me, but the GridFromEdges grid plan and GridFromPolygon seem to do the same thing just slightly different now.
Causing the first tile to be at the top when you plot the GridsFromEdges, and at the bottom with GridFromPolygon.

     grid_from_bounding_box = []
        pos_cls = RelativePosition if self.is_relative else AbsolutePosition
        for idx, (r, c) in enumerate(order.generate_indices(rows, cols)):
            grid_from_bounding_box.append(pos_cls(  # type: ignore [misc]
                x = c + x0 + (c + 0.5) * dx,
                y = r + y0 + (r + 0.5) * dy,
                row=r,
                col=c,
                name=f"{str(idx).zfill(4)}",
                )
            )

and

     pos_cls = RelativePosition if self.is_relative else AbsolutePosition
        for idx, (r, c) in enumerate(order.generate_indices(rows, cols)):
            yield pos_cls(  # type: ignore [misc]
                x=x0 + c * dx,
                y=y0 - r * dy, 
                row=r,
                col=c,
                name=f"{str(idx).zfill(4)}",
            )

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

def _offset_y(self, dy: float) -> float:
        return min(self.top, self.bottom)

@tlambert03
Copy link
Member

that explains why my polygons were acquiring from bottom to top! 😂

@tlambert03
Copy link
Member

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).

@rlokkie
Copy link
Contributor Author

rlokkie commented Jun 2, 2025

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.

rlokkie and others added 8 commits June 17, 2025 15:46
…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.
@tlambert03
Copy link
Member

tlambert03 commented Jun 18, 2025

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

@tlambert03 tlambert03 closed this Jun 18, 2025
@tlambert03 tlambert03 reopened this Jun 18, 2025
…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.
@rlokkie
Copy link
Contributor Author

rlokkie commented Jun 19, 2025

ah no worries!
Apologies for leaving it in a very sloppy state for a bit too long...
Final review would be appreciated, there is probably plenty to be improved still.

@tlambert03
Copy link
Member

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 polygon to vertices and work on some caching for performance issues... just so you know that the API may change slightly)

thanks for the great work!

@tlambert03 tlambert03 merged commit ed42cec into pymmcore-plus:main Aug 13, 2025
1 of 2 checks passed
@tlambert03 tlambert03 added the enhancement New feature or request label Aug 13, 2025
@rlokkie
Copy link
Contributor Author

rlokkie commented Aug 14, 2025

Great! Hope people will use it!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants