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 generation of random points in grid_plan #132

Merged
merged 26 commits into from
Sep 4, 2023

Conversation

fdrgsp
Copy link
Contributor

@fdrgsp fdrgsp commented Sep 1, 2023

This PR adds the RandomPoints plan, a type of grid_plan with the ability to generate a specified number of random points within a specified area.

The random generation is using numpy and if a random_seed attribute is specified, the generation of points is reproducible.

By setting allow_overlap=True together with fov_width and fov_height, the generated point will be at least fov_width and fov_height away from each other (Manhattan distance).


For quick test to see how it works:

from useq import RandomPoints
import matplotlib.pyplot as plt
from matplotlib.patches import Ellipse, Rectangle
fig, ax = plt.subplots()
w, h = (10, 10)
fw, fh = (1, 1)
shape = "ellipse"
rn = RandomPoints(
    num_points=10,
    max_width=w,
    max_height=h,
    shape=shape,
    random_seed=0,
    allow_overlap=False,
    fov_width=fw,
    fov_height=fh,
)
if shape == "ellipse":
    ax.add_patch(Ellipse((0, 0), w, h, fill=False, color="m"))
else:
    ax.add_patch(Rectangle((-w / 2, -h / 2), w, h, fill=False, color="m"))
for p in list(rn):
    x, y, _, _, _ = p
    plt.plot(x, y, "go")
    ax.add_patch(Rectangle((x - (fw/2), y - (fh/2)), fw, fh, fill=False,
 color="g"))
plt.axis("equal")
fig.show()

@codecov
Copy link

codecov bot commented Sep 1, 2023

Codecov Report

Patch coverage: 98.21% and project coverage change: -0.02% ⚠️

Comparison is base (56f3394) 98.19% compared to head (c16da53) 98.18%.

❗ Current head c16da53 differs from pull request most recent head abf5919. Consider uploading reports for the commit abf5919 to get more accurate results

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #132      +/-   ##
==========================================
- Coverage   98.19%   98.18%   -0.02%     
==========================================
  Files          14       14              
  Lines         833      880      +47     
==========================================
+ Hits          818      864      +46     
- Misses         15       16       +1     
Files Changed Coverage Δ
src/useq/_grid.py 99.46% <98.18%> (-0.54%) ⬇️
src/useq/__init__.py 100.00% <100.00%> (ø)

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

src/useq/__init__.py Outdated Show resolved Hide resolved
Copy link
Member

@tlambert03 tlambert03 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a general thought here, (wasn't a good specific place to comment inline in the code)

I love that you're exploring iterators here! but i think in this case it's gotten a bit more complicated than it needs to be. here's a few thoughts in no particular order:

  1. note that a numpy array is already an iterable, so returning iter(seed.uniform(0, 1, size=size)) doesn't actually gain anything over just returning seed.uniform(0, 1, size=size) (but it is a bit harder to reason about perhaps?)
  2. as written, MAX_ITERS could more accurately be called MIN_RANDOM_POINTS, because when you call iter_size = max(num_points, MAX_ITER), you're guaranteeing that the number of random generated points will be at least as bit as MAX_ITER, (unless num_points is greater). That's fine in this case, cause numpy is so fast... but the naming might be a bit surprising (even though the number of points does set an upper bound of the maximum possible number of iterations)
  3. doing stuff like math.sqrt(x0) * (max_width / 2) * math.cos(angle * 2 * math.pi) is going to be slower in a python for loop than it would be if you simply greedily calculated many thousands of candidate points in numpy and iterated over them.
  4. What began as a relatively simple pattern ([p for point in random_points() if _is_a_valid_point(p)] has gotten complicated and passed more parameters through to the deepest functions _is_a_valid_pointthan necessary, and thePointGenerator` callable type is now very hard to look at and reason about:
    PointGenerator = Callable[
        [int, float, float, Tuple[Optional[float], Optional[float]], bool, Optional[int]],
        Iterable[Tuple[float, float]],
    ]

Note that all shape types share the same _is_a_valid_point function... so I think it would be better not to pass min_distance and allow_overlap through all of those functions... instead just generate your 5000 points up front, and then iterate through those in the main iter function:

so, I'm thinking something like this:

class RandomPoints(_PointsPlan):
    def __iter__(self) -> Iterator[GridPosition]:  # type: ignore
        seed = np.random.RandomState(self.random_seed)
        func = _POINTS_GENERATORS[self.shape]
        points: list[Tuple[float, float]] = []
        for x, y in func(seed, self.max_width, self.max_height):
            if (
                self.allow_overlap
                or (None in (self.fov_width, self.fov_height))
                or _check_validity(self.fov_width, self.fov_height, points, x, y)
            ):
                yield GridPosition(x, y, 0, 0, True)
                points.append((x, y))
            if len(points) >= self.num_points:
                break

where:

PointGenerator = Callable[[np.random.RandomState, float, float], np.ndarray]
_POINTS_GENERATORS: dict[Shape, PointGenerator]

def _is_a_valid_point(
    points: list[Tuple[float, float]],
    x: float,
    y: float,
    min_dist_x: float,
    min_dist_y: float,
) -> bool:
    return not any(
        abs(x - point_x) < min_dist_x or abs(y - point_y) < min_dist_y
        for point_x, point_y in points
    )

and _random_points_in_ellipse and _random_points_in_rectangle just greedily make 5000 candidate points and return a numpy array

src/useq/_grid.py Outdated Show resolved Hide resolved
src/useq/_grid.py Outdated Show resolved Hide resolved
src/useq/_grid.py Outdated Show resolved Hide resolved
Copy link
Member

@tlambert03 tlambert03 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good! All set for merge?

@fdrgsp
Copy link
Contributor Author

fdrgsp commented Sep 4, 2023

looks good! All set for merge?

yes! Thanks for the help!

@tlambert03 tlambert03 merged commit c86fccc into pymmcore-plus:main Sep 4, 2023
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants