Improve voronoi computation#515
Conversation
Add tests for Voronoi polygon computation Test scenarios: - output schema and column types - density correctness - polygon containment within walkable area - pedestrian position inside own polygon - symmetric placement producing equal areas - full area coverage (no gaps) - cutoff behavior (area reduction, circle bounds, large cutoff, quad segments) - blind points on/off with warning emission - multiple frames, non-sequential IDs and frames - single pedestrian coverage - non-convex and obstacle walkable areas - collinear and boundary pedestrians - determinism - polygon validity - close pedestrians - non-overlapping polygons - multipolygon resolution for complex geometrie
Replace manual scipy-based Voronoi polygon reconstruction with shapely.voronoi_polygons(ordered=True). Replacing manually written code from stackoverflow with library call. - Vectorize intersection and cutoff using shapely array ops instead of per-polygon Python loops - Extract _resolve_multipolygons helper for GeometryCollection results from non-convex walkable area intersections - Bump Shapely minimum version to 2.1 (ordered parameter)
Codecov Report✅ All modified and coverable lines are covered by tests. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR updates PedPy’s individual Voronoi computation to use Shapely’s built-in Voronoi polygon generation (with ordering), replacing the prior SciPy-based reconstruction and aiming to improve performance and robustness. It also adds extensive unit tests and bumps the minimum Shapely version to support the new API.
Changes:
- Replace SciPy Voronoi reconstruction with
shapely.voronoi_polygons(..., ordered=True)and vectorized Shapely array operations. - Add
_resolve_multipolygonshelper to handle non-Polygon intersection outputs. - Bump Shapely minimum version to
>=2.1and add unit tests forcompute_individual_voronoi_polygons.
Reviewed changes
Copilot reviewed 4 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
pedpy/methods/method_utils.py |
Switch Voronoi generation to Shapely, vectorize clipping/cutoff, add _resolve_multipolygons. |
tests/unit_tests/methods/test_compute_individual_voronoi_polygons.py |
Add comprehensive unit tests for Voronoi polygon computation and helper behavior. |
requirements.txt |
Bump Shapely minimum version to >=2.1,<3.0. |
pyproject.toml |
Bump Shapely minimum version to >=2.1,<3.0. |
.pre-commit-config.yaml |
Update pre-commit hook environment to use Shapely ~=2.1. |
Comments suppressed due to low confidence (2)
pedpy/methods/method_utils.py:549
- The
use_blind_pointsdocstring no longer matches the implementation: the function now relies onshapely.voronoi_polygons(..., extend_to=...)and does not add 4 external “blind points”, but the docstring still claims those points are added and have no effect on polygon sizes. Please update the docstring to describe the current behavior (including whatuse_blind_points=Falsedoes for frames with <4 pedestrians).
For allowing the computation of the Voronoi polygons when less than 4
pedestrians are in the walkable area, 4 extra points will be added outside
the walkable area with a significant distance. These will have no effect
on the size of the computed Voronoi polygons. This behavior can be turned
off by setting :code:`use_blind_points = False`. When turned off no Voronoi
polygons will be computed for frames with less than 4 persons, also
pedestrians walking in a line can lead to issues in the computation of the
Voronoi tesselation.
Args:
traj_data (TrajectoryData): trajectory data
walkable_area (WalkableArea): bounding area, where pedestrian are
supposed to walk
cut_off (Cutoff): cutoff information, which provide the largest
possible extend of a single Voronoi polygon
use_blind_points (bool): adds extra 4 points outside the walkable area
to also compute voronoi cells when less than 4 peds are in the
walkable area (default: on!)
Returns:
DataFrame containing the columns 'id', 'frame','polygon' (
:class:`shapely.Polygon`), and 'density' in :math:`1/m^2`.
"""
all_ids = []
all_frames = []
all_polygons = []
wa_polygon = walkable_area.polygon
for frame, peds_in_frame in traj_data.data.groupby(traj_data.data.frame):
n_peds = len(peds_in_frame)
if not use_blind_points and n_peds < 4:
_log.warning(
f"Not enough pedestrians (N="
f"{n_peds}) available to "
f"calculate Voronoi cells for frame = {frame}. "
f"Consider enable use of blind points."
)
continue
pedpy/methods/method_utils.py:548
- Log warning text has a grammatical issue: "Consider enable use of blind points." reads awkwardly. Consider changing it to something like "Consider enabling
use_blind_points." to make the guidance clearer.
_log.warning(
f"Not enough pedestrians (N="
f"{n_peds}) available to "
f"calculate Voronoi cells for frame = {frame}. "
f"Consider enable use of blind points."
)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| within_mask = shapely.within(points[idx], parts) | ||
| if within_mask.any(): | ||
| resolved[idx] = parts[within_mask][0] | ||
| else: | ||
| # Fallback: use the part nearest to the point | ||
| distances = shapely.distance(points[idx], parts) | ||
| resolved[idx] = parts[np.argmin(distances)] |
There was a problem hiding this comment.
_resolve_multipolygons can return a non-Polygon geometry: for a GeometryCollection that contains non-polygon parts (e.g., lines/points), the fallback path picks the nearest part without filtering by polygon type. This can yield a LineString/Point with zero area and later produce invalid densities (division by zero) or violate the function’s promise to return polygons. Filter parts down to polygonal parts before within/distance selection, and handle the case where no polygon parts exist (e.g., raise or return an empty polygon and drop that row).
| within_mask = shapely.within(points[idx], parts) | |
| if within_mask.any(): | |
| resolved[idx] = parts[within_mask][0] | |
| else: | |
| # Fallback: use the part nearest to the point | |
| distances = shapely.distance(points[idx], parts) | |
| resolved[idx] = parts[np.argmin(distances)] | |
| # Filter to polygonal parts only (type_id == 3 corresponds to Polygon) | |
| parts_type_ids = shapely.get_type_id(parts) | |
| polygon_parts = parts[parts_type_ids == 3] | |
| if len(polygon_parts) == 0: | |
| # No polygonal parts available: fall back to an empty Polygon to | |
| # preserve the return type contract and avoid zero-area non-polygons. | |
| _log.warning( | |
| "Geometry at index %s has no polygonal parts; using empty Polygon.", | |
| idx, | |
| ) | |
| resolved[idx] = shapely.Polygon() | |
| continue | |
| within_mask = shapely.within(points[idx], polygon_parts) | |
| if within_mask.any(): | |
| resolved[idx] = polygon_parts[within_mask][0] | |
| else: | |
| # Fallback: use the polygon part nearest to the point | |
| distances = shapely.distance(points[idx], polygon_parts) | |
| resolved[idx] = polygon_parts[np.argmin(distances)] |
Replace manual scipy-based Voronoi polygon reconstruction with
shapely.voronoi_polygons(ordered=True). Replacing manually written code
from stackoverflow with library call.
instead of per-polygon Python loops
results from non-convex walkable area intersections
Also add unit tests for the function.
Benchmark:
compute_individual_voronoi_polygonsData:
bi_corr_400_b_08.txt(777,733 rows, 5,756 frames, 703 pedestrians, 25 fps)