ObjectNat 1.2.0__py3-none-any.whl → 1.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ObjectNat might be problematic. Click here for more details.

Files changed (35) hide show
  1. objectnat/__init__.py +9 -13
  2. objectnat/_api.py +14 -14
  3. objectnat/_config.py +47 -47
  4. objectnat/_version.py +1 -1
  5. objectnat/methods/coverage_zones/__init__.py +3 -3
  6. objectnat/methods/coverage_zones/graph_coverage.py +98 -108
  7. objectnat/methods/coverage_zones/radius_voronoi_coverage.py +37 -45
  8. objectnat/methods/coverage_zones/stepped_coverage.py +126 -142
  9. objectnat/methods/isochrones/__init__.py +1 -1
  10. objectnat/methods/isochrones/isochrone_utils.py +167 -167
  11. objectnat/methods/isochrones/isochrones.py +262 -299
  12. objectnat/methods/noise/__init__.py +3 -4
  13. objectnat/methods/noise/noise_init_data.py +10 -10
  14. objectnat/methods/noise/noise_reduce.py +155 -155
  15. objectnat/methods/noise/noise_simulation.py +452 -440
  16. objectnat/methods/noise/noise_simulation_simplified.py +209 -135
  17. objectnat/methods/point_clustering/__init__.py +1 -1
  18. objectnat/methods/point_clustering/cluster_points_in_polygons.py +115 -116
  19. objectnat/methods/provision/__init__.py +1 -1
  20. objectnat/methods/provision/provision.py +117 -110
  21. objectnat/methods/provision/provision_exceptions.py +59 -59
  22. objectnat/methods/provision/provision_model.py +337 -337
  23. objectnat/methods/utils/__init__.py +1 -1
  24. objectnat/methods/utils/geom_utils.py +173 -173
  25. objectnat/methods/utils/graph_utils.py +306 -320
  26. objectnat/methods/utils/math_utils.py +32 -32
  27. objectnat/methods/visibility/__init__.py +6 -6
  28. objectnat/methods/visibility/visibility_analysis.py +470 -511
  29. {objectnat-1.2.0.dist-info → objectnat-1.2.1.dist-info}/LICENSE.txt +28 -28
  30. objectnat-1.2.1.dist-info/METADATA +115 -0
  31. objectnat-1.2.1.dist-info/RECORD +33 -0
  32. objectnat/methods/noise/noise_exceptions.py +0 -14
  33. objectnat-1.2.0.dist-info/METADATA +0 -148
  34. objectnat-1.2.0.dist-info/RECORD +0 -34
  35. {objectnat-1.2.0.dist-info → objectnat-1.2.1.dist-info}/WHEEL +0 -0
@@ -1,511 +1,470 @@
1
- import math
2
- from multiprocessing import cpu_count
3
-
4
- import geopandas as gpd
5
- import numpy as np
6
- import pandas as pd
7
- from pandarallel import pandarallel
8
- from shapely import LineString, MultiPolygon, Point, Polygon
9
- from shapely.ops import unary_union
10
- from tqdm.contrib.concurrent import process_map
11
-
12
- from objectnat import config
13
- from objectnat.methods.utils.geom_utils import (
14
- combine_geometry,
15
- explode_linestring,
16
- get_point_from_a_thorough_b,
17
- point_side_of_line,
18
- polygons_to_multilinestring,
19
- )
20
- from objectnat.methods.utils.math_utils import min_max_normalization
21
-
22
- logger = config.logger
23
-
24
-
25
- def get_visibility_accurate(
26
- point_from: Point | gpd.GeoDataFrame, obstacles: gpd.GeoDataFrame, view_distance, return_max_view_dist=False
27
- ) -> Polygon | gpd.GeoDataFrame | tuple[Polygon | gpd.GeoDataFrame, float]:
28
- """
29
- Function to get accurate visibility from a given point to buildings within a given distance.
30
-
31
- Parameters
32
- ----------
33
- point_from : Point | gpd.GeoDataFrame
34
- The point or GeoDataFrame with 1 point from which the line of sight is drawn.
35
- If Point is provided it should be in the same crs as obstacles
36
- obstacles : gpd.GeoDataFrame
37
- A GeoDataFrame containing the geometry of the obstacles.
38
- view_distance : float
39
- The distance of view from the point.
40
- return_max_view_dist
41
- If True, the max view distance is returned with view polygon in tuple.
42
-
43
- Returns
44
- -------
45
- Polygon | gpd.GeoDataFrame | tuple[Polygon | gpd.GeoDataFrame, float]
46
- A polygon representing the area of visibility from the given point or polygon with max view distance.
47
- if point_from was a GeoDataFrame, return GeoDataFrame with one feature, else Polygon.
48
-
49
- Notes
50
- -----
51
- If a quick result is important, consider using the `get_visibility()` function instead.
52
- However, please note that `get_visibility()` may provide less accurate results.
53
-
54
- Examples
55
- --------
56
- >>> from objectnat import get_visibility_accurate
57
- >>> obstacles = gpd.read_parquet('examples_data/buildings.parquet')
58
- >>> point_from = gpd.GeoDataFrame(geometry=[Point(30.2312112, 59.9482336)], crs=4326)
59
- >>> result = get_visibility_accurate(point_from, obstacles, 500)
60
- """
61
-
62
- def find_furthest_point(point_from, view_polygon):
63
- try:
64
- res = round(max(Point(coords).distance(point_from) for coords in view_polygon.exterior.coords), 1)
65
- except Exception as e:
66
- print(view_polygon)
67
- raise e
68
- return res
69
-
70
- local_crs = None
71
- original_crs = None
72
- return_gdf = False
73
- if isinstance(point_from, gpd.GeoDataFrame):
74
- original_crs = point_from.crs
75
- return_gdf = True
76
- if len(obstacles) > 0:
77
- local_crs = obstacles.estimate_utm_crs()
78
- else:
79
- local_crs = point_from.estimate_utm_crs()
80
- obstacles = obstacles.to_crs(local_crs)
81
- point_from = point_from.to_crs(local_crs)
82
- if len(point_from) > 1:
83
- logger.warning(
84
- f"This method processes only single point. The GeoDataFrame contains {len(point_from)} points - "
85
- "only the first geometry will be used for isochrone calculation. "
86
- )
87
- point_from = point_from.iloc[0].geometry
88
- else:
89
- obstacles = obstacles.copy()
90
- if obstacles.contains(point_from).any():
91
- return Polygon()
92
- obstacles.reset_index(inplace=True, drop=True)
93
- point_buffer = point_from.buffer(view_distance, resolution=32)
94
- allowed_geom_types = ["MultiPolygon", "Polygon", "LineString", "MultiLineString"]
95
- obstacles = obstacles[obstacles.geom_type.isin(allowed_geom_types)]
96
- s = obstacles.intersects(point_buffer)
97
- obstacles_in_buffer = obstacles.loc[s[s].index].geometry
98
-
99
- buildings_lines_in_buffer = gpd.GeoSeries(
100
- pd.Series(
101
- obstacles_in_buffer.apply(polygons_to_multilinestring).explode(index_parts=False).apply(explode_linestring)
102
- ).explode()
103
- )
104
-
105
- buildings_lines_in_buffer = buildings_lines_in_buffer.loc[buildings_lines_in_buffer.intersects(point_buffer)]
106
-
107
- buildings_in_buffer_points = gpd.GeoSeries(
108
- [Point(line.coords[0]) for line in buildings_lines_in_buffer.geometry]
109
- + [Point(line.coords[-1]) for line in buildings_lines_in_buffer.geometry]
110
- )
111
-
112
- max_dist = max(view_distance, buildings_in_buffer_points.distance(point_from).max())
113
- polygons = []
114
- buildings_lines_in_buffer = gpd.GeoDataFrame(geometry=buildings_lines_in_buffer, crs=obstacles.crs).reset_index()
115
- logger.debug("Calculation vis polygon")
116
- while not buildings_lines_in_buffer.empty:
117
- gdf_sindex = buildings_lines_in_buffer.sindex
118
- # TODO check if 2 walls are nearest and use the widest angle between points
119
- nearest_wall_sind = gdf_sindex.nearest(point_from, return_all=False, max_distance=max_dist)
120
- nearest_wall = buildings_lines_in_buffer.loc[nearest_wall_sind[1]].iloc[0]
121
- wall_points = [Point(coords) for coords in nearest_wall.geometry.coords]
122
-
123
- # Calculate angles and sort by angle
124
- points_with_angle = sorted(
125
- [(pt, math.atan2(pt.y - point_from.y, pt.x - point_from.x)) for pt in wall_points], key=lambda x: x[1]
126
- )
127
- delta_angle = 2 * math.pi + points_with_angle[0][1] - points_with_angle[-1][1]
128
- if round(delta_angle, 10) == round(math.pi, 10):
129
- wall_b_centroid = obstacles_in_buffer.loc[nearest_wall["index"]].centroid
130
- p1 = get_point_from_a_thorough_b(point_from, points_with_angle[0][0], max_dist)
131
- p2 = get_point_from_a_thorough_b(point_from, points_with_angle[1][0], max_dist)
132
- polygon = LineString([p1, p2])
133
- polygon = polygon.buffer(
134
- distance=max_dist * point_side_of_line(polygon, wall_b_centroid), single_sided=True
135
- )
136
- else:
137
- if delta_angle > math.pi:
138
- delta_angle = 2 * math.pi - delta_angle
139
- a = math.sqrt((max_dist**2) * (1 + (math.tan(delta_angle / 2) ** 2)))
140
- p1 = get_point_from_a_thorough_b(point_from, points_with_angle[0][0], a)
141
- p2 = get_point_from_a_thorough_b(point_from, points_with_angle[-1][0], a)
142
- polygon = Polygon([points_with_angle[0][0], p1, p2, points_with_angle[1][0]])
143
-
144
- polygons.append(polygon)
145
- buildings_lines_in_buffer.drop(nearest_wall_sind[1], inplace=True)
146
-
147
- if not polygon.is_valid or polygon.area < 1:
148
- buildings_lines_in_buffer.reset_index(drop=True, inplace=True)
149
- continue
150
-
151
- lines_to_kick = buildings_lines_in_buffer.within(polygon)
152
- buildings_lines_in_buffer = buildings_lines_in_buffer.loc[~lines_to_kick]
153
- buildings_lines_in_buffer.reset_index(drop=True, inplace=True)
154
- logger.debug("Done calculating!")
155
- res = point_buffer.difference(unary_union(polygons + obstacles_in_buffer.to_list()))
156
-
157
- if isinstance(res, MultiPolygon):
158
- res = list(res.geoms)
159
- for polygon in res:
160
- if polygon.intersects(point_from):
161
- res = polygon
162
- break
163
-
164
- if return_gdf:
165
- res = gpd.GeoDataFrame(geometry=[res], crs=local_crs).to_crs(original_crs)
166
-
167
- if return_max_view_dist:
168
- return res, find_furthest_point(point_from, res)
169
- return res
170
-
171
-
172
- def get_visibility(
173
- point_from: Point | gpd.GeoDataFrame, obstacles: gpd.GeoDataFrame, view_distance: float, resolution: int = 32
174
- ) -> Polygon | gpd.GeoDataFrame:
175
- """
176
- Function to get a quick estimate of visibility from a given point to buildings within a given distance.
177
-
178
- Parameters
179
- ----------
180
- point_from : Point | gpd.GeoDataFrame
181
- The point or GeoDataFrame with 1 point from which the line of sight is drawn.
182
- If Point is provided it should be in the same crs as obstacles
183
- obstacles : gpd.GeoDataFrame
184
- A GeoDataFrame containing the geometry of the buildings.
185
- view_distance : float
186
- The distance of view from the point.
187
- resolution: int
188
- Buffer resolution for more accuracy (may give result slower)
189
-
190
- Returns
191
- -------
192
- Polygon | gpd.GeoDataFrame
193
- A polygon representing the area of visibility from the given point.
194
- if point_from was a GeoDataFrame, return GeoDataFrame with one feature, else Polygon.
195
-
196
- Notes
197
- -----
198
- This function provides a quicker but less accurate result compared to `get_visibility_accurate()`.
199
- If accuracy is important, consider using `get_visibility_accurate()` instead.
200
-
201
- Examples
202
- --------
203
- >>> from objectnat import get_visibility
204
- >>> obstacles = gpd.read_parquet('examples_data/buildings.parquet')
205
- >>> point_from = gpd.GeoDataFrame(geometry=[Point(30.2312112, 59.9482336)], crs=4326)
206
- >>> result = get_visibility(point_from, obstacles, 500)
207
- """
208
- return_gdf = False
209
- if isinstance(point_from, gpd.GeoDataFrame):
210
- original_crs = point_from.crs
211
- return_gdf = True
212
- if len(obstacles) > 0:
213
- local_crs = obstacles.estimate_utm_crs()
214
- else:
215
- local_crs = point_from.estimate_utm_crs()
216
- obstacles = obstacles.to_crs(local_crs)
217
- point_from = point_from.to_crs(local_crs)
218
- if len(point_from) > 1:
219
- logger.warning(
220
- f"This method processes only single point. The GeoDataFrame contains {len(point_from)} points - "
221
- "only the first geometry will be used for isochrone calculation. "
222
- )
223
- point_from = point_from.iloc[0].geometry
224
- else:
225
- obstacles = obstacles.copy()
226
- point_buffer = point_from.buffer(view_distance, resolution=resolution)
227
- s = obstacles.intersects(point_buffer)
228
- buildings_in_buffer = obstacles.loc[s[s].index].reset_index(drop=True)
229
- buffer_exterior_ = list(point_buffer.exterior.coords)
230
- line_geometry = [LineString([point_from, ext]) for ext in buffer_exterior_]
231
- buffer_lines_gdf = gpd.GeoDataFrame(geometry=line_geometry)
232
- united_buildings = buildings_in_buffer.union_all()
233
- if united_buildings:
234
- splited_lines = buffer_lines_gdf["geometry"].apply(lambda x: x.difference(united_buildings))
235
- else:
236
- splited_lines = buffer_lines_gdf["geometry"]
237
-
238
- splited_lines_gdf = gpd.GeoDataFrame(geometry=splited_lines).explode(index_parts=True)
239
- splited_lines_list = []
240
-
241
- for _, v in splited_lines_gdf.groupby(level=0):
242
- splited_lines_list.append(v.iloc[0]["geometry"].coords[-1])
243
- circuit = Polygon(splited_lines_list)
244
- if united_buildings:
245
- circuit = circuit.difference(united_buildings)
246
-
247
- if return_gdf:
248
- circuit = gpd.GeoDataFrame(geometry=[circuit], crs=local_crs).to_crs(original_crs)
249
- return circuit
250
-
251
-
252
- def get_visibilities_from_points(
253
- points: gpd.GeoDataFrame,
254
- obstacles: gpd.GeoDataFrame,
255
- view_distance: int,
256
- sectors_n=None,
257
- max_workers: int = cpu_count(),
258
- ) -> list[Polygon]:
259
- """
260
- Calculate visibility polygons from a set of points considering obstacles within a specified view distance.
261
-
262
- Parameters
263
- ----------
264
- points : gpd.GeoDataFrame
265
- GeoDataFrame containing the points from which visibility is calculated.
266
- obstacles : gpd.GeoDataFrame
267
- GeoDataFrame containing the obstacles that block visibility.
268
- view_distance : int
269
- The maximum distance from each point within which visibility is calculated.
270
- sectors_n : int, optional
271
- Number of sectors to divide the view into for more detailed visibility calculations. Defaults to None.
272
- max_workers: int, optional
273
- Maximum workers in multiproccesing, multipocessing.cpu_count() by default.
274
-
275
- Returns
276
- -------
277
- list[Polygon]
278
- A list of visibility polygons for each input point.
279
-
280
- Notes
281
- -----
282
- This function uses `get_visibility_accurate()` in multiprocessing way.
283
-
284
- """
285
- if points.crs != obstacles.crs:
286
- raise ValueError(f"CRS mismatch, points crs:{points.crs} != obstacles crs:{obstacles.crs}")
287
- if points.crs.is_geographic:
288
- logger.warning("Points crs is geographic, it may produce invalid results")
289
- # remove points inside polygons
290
- joined = gpd.sjoin(points, obstacles, how="left", predicate="intersects")
291
- points = joined[joined.index_right.isnull()]
292
-
293
- # remove unused obstacles
294
- points_view = points.geometry.buffer(view_distance).union_all()
295
- s = obstacles.intersects(points_view)
296
- buildings_in_buffer = obstacles.loc[s[s].index].reset_index(drop=True)
297
-
298
- buildings_in_buffer.geometry = buildings_in_buffer.geometry.apply(
299
- lambda geom: MultiPolygon([geom]) if isinstance(geom, Polygon) else geom
300
- )
301
- args = [(point, buildings_in_buffer, view_distance, sectors_n) for point in points.geometry]
302
- all_visions = process_map(
303
- _multiprocess_get_vis,
304
- args,
305
- chunksize=5,
306
- desc="Calculating Visibility Catchment Area from each Point, it might take a while for a "
307
- "big amount of points",
308
- max_workers=max_workers,
309
- )
310
-
311
- # could return sectorized visions if sectors_n is set
312
- return all_visions
313
-
314
-
315
- def calculate_visibility_catchment_area(
316
- points: gpd.GeoDataFrame, obstacles: gpd.GeoDataFrame, view_distance: int | float, max_workers: int = cpu_count()
317
- ) -> gpd.GeoDataFrame:
318
- """
319
- Calculate visibility catchment areas for a large urban area based on given points and obstacles.
320
- This function is designed to work with at least 1000 points spaced 10-20 meters apart for optimal results.
321
- Points can be generated using a road graph.
322
-
323
- Parameters
324
- ----------
325
- points : gpd.GeoDataFrame
326
- GeoDataFrame containing the points from which visibility is calculated.
327
- obstacles : gpd.GeoDataFrame
328
- GeoDataFrame containing the obstacles that block visibility.
329
- view_distance : int
330
- The maximum distance from each point within which visibility is calculated.
331
- max_workers: int
332
- Maximum workers in multiproccesing, multipocessing.cpu_count() by default.
333
-
334
- Returns
335
- -------
336
- gpd.GeoDataFrame
337
- GeoDataFrame containing the calculated visibility catchment areas.
338
-
339
- Examples
340
- --------
341
- >>> import geopandas as gpd
342
- >>> from shapely.geometry import Point, Polygon
343
- >>> points = gpd.read_file('points.shp')
344
- >>> obstacles = gpd.read_file('obstacles.shp')
345
- >>> view_distance = 1000
346
-
347
- >>> visibility_areas = calculate_visibility_catchment_area(points, obstacles, view_distance)
348
- >>> visibility_areas
349
- """
350
-
351
- def filter_geoms(x):
352
- if x.geom_type == "GeometryCollection":
353
- return MultiPolygon([y for y in x.geoms if y.geom_type in ["Polygon", "MultiPolygon"]])
354
- return x
355
-
356
- def calc_group_factor(x):
357
- # pylint: disable-next=redefined-outer-name,reimported,import-outside-toplevel
358
- import numpy as np
359
-
360
- return np.mean(x.new_ratio) * x.count_n
361
-
362
- def unary_union_groups(x):
363
- # pylint: disable-next=redefined-outer-name,reimported,import-outside-toplevel
364
- from shapely import MultiPolygon
365
-
366
- # pylint: disable-next=redefined-outer-name,reimported,import-outside-toplevel
367
- from shapely.ops import unary_union
368
-
369
- return unary_union(MultiPolygon(list(x["geometry"])).buffer(0))
370
-
371
- pandarallel.initialize(progress_bar=True, verbose=0)
372
-
373
- local_crs = obstacles.estimate_utm_crs()
374
- obstacles = obstacles.to_crs(local_crs)
375
- points = points.to_crs(local_crs)
376
-
377
- sectors_n = 12
378
- logger.info("Calculating Visibility Catchment Area from each point")
379
- all_visions_sectorized = get_visibilities_from_points(points, obstacles, view_distance, sectors_n, max_workers)
380
- all_visions_sectorized = gpd.GeoDataFrame(
381
- geometry=[item for sublist in all_visions_sectorized for item in sublist], crs=local_crs
382
- )
383
- logger.info("Calculating non-vision part...")
384
- all_visions_unary = all_visions_sectorized.union_all()
385
- convex = all_visions_unary.convex_hull
386
- dif = convex.difference(all_visions_unary)
387
-
388
- del convex, all_visions_unary
389
-
390
- buf_area = (math.pi * view_distance**2) / sectors_n
391
- all_visions_sectorized["ratio"] = all_visions_sectorized.area / buf_area
392
- all_visions_sectorized["ratio"] = min_max_normalization(
393
- all_visions_sectorized["ratio"].values, new_min=1, new_max=10
394
- )
395
- groups = all_visions_sectorized.sample(frac=1).groupby(all_visions_sectorized.index // 6000)
396
- groups = [group for _, group in groups]
397
-
398
- del all_visions_sectorized
399
-
400
- groups_result = process_map(
401
- _process_group,
402
- groups,
403
- desc="Counting intersections in each group...",
404
- max_workers=max_workers,
405
- )
406
- logger.info("Calculating all groups intersection...")
407
- all_in = combine_geometry(gpd.GeoDataFrame(data=pd.concat(groups_result), geometry="geometry", crs=local_crs))
408
-
409
- del groups_result
410
-
411
- all_in["count_n"] = all_in["index_right"].apply(len)
412
-
413
- logger.info("Calculating intersection's parameters")
414
- all_in["factor"] = all_in.parallel_apply(calc_group_factor, axis=1)
415
- threshold = all_in["factor"].quantile(0.3)
416
- all_in = all_in[all_in["factor"] > threshold]
417
-
418
- all_in["factor_normalized"] = np.round(
419
- min_max_normalization(np.sqrt(all_in["factor"].values), new_min=1, new_max=5)
420
- ).astype(int)
421
- logger.info("Calculating normalized groups geometry...")
422
- all_in = all_in.groupby("factor_normalized").parallel_apply(unary_union_groups).reset_index()
423
- all_in = gpd.GeoDataFrame(data=all_in.rename(columns={0: "geometry"}), geometry="geometry", crs=32636)
424
-
425
- all_in = all_in.explode(index_parts=True).reset_index(drop=True)
426
- all_in["area"] = all_in.area
427
- threshold = all_in["area"].quantile(0.9)
428
- all_in = all_in[all_in["area"] > threshold]
429
- all_in = all_in.groupby("factor_normalized").apply(unary_union_groups).reset_index()
430
- all_in = gpd.GeoDataFrame(data=all_in.rename(columns={0: "geometry"}), geometry="geometry", crs=32636)
431
-
432
- all_in.geometry = all_in.geometry.buffer(20).buffer(-20).difference(dif)
433
-
434
- all_in.sort_values(by="factor_normalized", ascending=False, inplace=True)
435
- all_in.reset_index(drop=True, inplace=True)
436
- logger.info("Smoothing normalized groups geometry...")
437
- for ind, row in all_in.iloc[:-1].iterrows():
438
- for ind2 in range(ind + 1, len(all_in)):
439
- current_geometry = all_in.at[ind2, "geometry"]
440
- all_in.at[ind2, "geometry"] = current_geometry.difference(row.geometry)
441
- all_in["geometry"] = all_in["geometry"].apply(filter_geoms)
442
-
443
- all_in = all_in.explode(index_parts=True)
444
- logger.info("Done!")
445
- return all_in
446
-
447
-
448
- def _multiprocess_get_vis(args): # pragma: no cover
449
- point, buildings, view_distance, sectors_n = args
450
- result = get_visibility_accurate(point, buildings, view_distance)
451
-
452
- if sectors_n is not None:
453
- sectors = []
454
-
455
- cx, cy = point.x, point.y
456
-
457
- angle_increment = 2 * math.pi / sectors_n
458
- view_distance = math.sqrt((view_distance**2) * (1 + (math.tan(angle_increment / 2) ** 2)))
459
- for i in range(sectors_n):
460
- angle1 = i * angle_increment
461
- angle2 = (i + 1) * angle_increment
462
-
463
- x1, y1 = cx + view_distance * math.cos(angle1), cy + view_distance * math.sin(angle1)
464
- x2, y2 = cx + view_distance * math.cos(angle2), cy + view_distance * math.sin(angle2)
465
-
466
- sector_triangle = Polygon([point, (x1, y1), (x2, y2)])
467
- sector = result.intersection(sector_triangle)
468
-
469
- if not sector.is_empty:
470
- sectors.append(sector)
471
- result = sectors
472
- return result
473
-
474
-
475
- def _process_group(group): # pragma: no cover
476
- geom = group
477
- combined_geometry = combine_geometry(geom)
478
- combined_geometry.drop(columns=["index", "index_right"], inplace=True)
479
- combined_geometry["count_n"] = combined_geometry["ratio"].apply(len)
480
- combined_geometry["new_ratio"] = combined_geometry.apply(
481
- lambda x: np.power(np.prod(x.ratio), 1 / x.count_n) * x.count_n, axis=1
482
- )
483
-
484
- threshold = combined_geometry["new_ratio"].quantile(0.25)
485
- combined_geometry = combined_geometry[combined_geometry["new_ratio"] > threshold]
486
-
487
- combined_geometry["new_ratio_normalized"] = min_max_normalization(
488
- combined_geometry["new_ratio"].values, new_min=1, new_max=10
489
- )
490
-
491
- combined_geometry["new_ratio_normalized"] = np.round(combined_geometry["new_ratio_normalized"]).astype(int)
492
-
493
- result_union = (
494
- combined_geometry.groupby("new_ratio_normalized")
495
- .agg({"geometry": lambda x: unary_union(MultiPolygon(list(x)).buffer(0))})
496
- .reset_index(drop=True)
497
- )
498
- result_union.set_geometry("geometry", inplace=True)
499
- result_union.set_crs(geom.crs, inplace=True)
500
-
501
- result_union = result_union.explode("geometry", index_parts=False).reset_index(drop=True)
502
-
503
- representative_points = combined_geometry.copy()
504
- representative_points["geometry"] = representative_points["geometry"].representative_point()
505
-
506
- joined = gpd.sjoin(result_union, representative_points, how="inner", predicate="contains").reset_index()
507
- joined = joined.groupby("index").agg({"geometry": "first", "new_ratio": lambda x: np.mean(list(x))})
508
-
509
- joined.set_geometry("geometry", inplace=True)
510
- joined.set_crs(geom.crs, inplace=True)
511
- return joined
1
+ import math
2
+ from multiprocessing import cpu_count
3
+
4
+ import geopandas as gpd
5
+ import numpy as np
6
+ import pandas as pd
7
+ from pandarallel import pandarallel
8
+ from shapely import LineString, MultiPolygon, Point, Polygon
9
+ from shapely.ops import unary_union
10
+ from tqdm.contrib.concurrent import process_map
11
+
12
+ from objectnat import config
13
+ from objectnat.methods.utils.geom_utils import (
14
+ combine_geometry,
15
+ explode_linestring,
16
+ get_point_from_a_thorough_b,
17
+ point_side_of_line,
18
+ polygons_to_multilinestring,
19
+ )
20
+ from objectnat.methods.utils.math_utils import min_max_normalization
21
+
22
+ logger = config.logger
23
+
24
+
25
+ def get_visibility_accurate(
26
+ point_from: Point | gpd.GeoDataFrame, obstacles: gpd.GeoDataFrame, view_distance, return_max_view_dist=False
27
+ ) -> Polygon | gpd.GeoDataFrame | tuple[Polygon | gpd.GeoDataFrame, float]:
28
+ """
29
+ Function to get accurate visibility from a given point to buildings within a given distance.
30
+
31
+ Parameters:
32
+ point_from (Point | gpd.GeoDataFrame):
33
+ The point or GeoDataFrame with 1 point from which the line of sight is drawn.
34
+ If Point is provided it should be in the same crs as obstacles.
35
+ obstacles (gpd.GeoDataFrame):
36
+ A GeoDataFrame containing the geometry of the obstacles.
37
+ view_distance (float):
38
+ The distance of view from the point.
39
+ return_max_view_dist (bool):
40
+ If True, the max view distance is returned with view polygon in tuple.
41
+
42
+ Returns:
43
+ (Polygon | gpd.GeoDataFrame | tuple[Polygon | gpd.GeoDataFrame, float]):
44
+ A polygon representing the area of visibility from the given point or polygon with max view distance.
45
+ if point_from was a GeoDataFrame, return GeoDataFrame with one feature, else Polygon.
46
+
47
+ Notes:
48
+ If a quick result is important, consider using the `get_visibility()` function instead.
49
+ However, please note that `get_visibility()` may provide less accurate results.
50
+ """
51
+
52
+ def find_furthest_point(point_from, view_polygon):
53
+ try:
54
+ res = round(max(Point(coords).distance(point_from) for coords in view_polygon.exterior.coords), 1)
55
+ except Exception as e:
56
+ print(view_polygon)
57
+ raise e
58
+ return res
59
+
60
+ local_crs = None
61
+ original_crs = None
62
+ return_gdf = False
63
+ if isinstance(point_from, gpd.GeoDataFrame):
64
+ original_crs = point_from.crs
65
+ return_gdf = True
66
+ if len(obstacles) > 0:
67
+ local_crs = obstacles.estimate_utm_crs()
68
+ else:
69
+ local_crs = point_from.estimate_utm_crs()
70
+ obstacles = obstacles.to_crs(local_crs)
71
+ point_from = point_from.to_crs(local_crs)
72
+ if len(point_from) > 1:
73
+ logger.warning(
74
+ f"This method processes only single point. The GeoDataFrame contains {len(point_from)} points - "
75
+ "only the first geometry will be used for isochrone calculation. "
76
+ )
77
+ point_from = point_from.iloc[0].geometry
78
+ else:
79
+ obstacles = obstacles.copy()
80
+ if obstacles.contains(point_from).any():
81
+ return Polygon()
82
+ obstacles.reset_index(inplace=True, drop=True)
83
+ point_buffer = point_from.buffer(view_distance, resolution=32)
84
+ allowed_geom_types = ["MultiPolygon", "Polygon", "LineString", "MultiLineString"]
85
+ obstacles = obstacles[obstacles.geom_type.isin(allowed_geom_types)]
86
+ s = obstacles.intersects(point_buffer)
87
+ obstacles_in_buffer = obstacles.loc[s[s].index].geometry
88
+
89
+ buildings_lines_in_buffer = gpd.GeoSeries(
90
+ pd.Series(
91
+ obstacles_in_buffer.apply(polygons_to_multilinestring).explode(index_parts=False).apply(explode_linestring)
92
+ ).explode()
93
+ )
94
+
95
+ buildings_lines_in_buffer = buildings_lines_in_buffer.loc[buildings_lines_in_buffer.intersects(point_buffer)]
96
+
97
+ buildings_in_buffer_points = gpd.GeoSeries(
98
+ [Point(line.coords[0]) for line in buildings_lines_in_buffer.geometry]
99
+ + [Point(line.coords[-1]) for line in buildings_lines_in_buffer.geometry]
100
+ )
101
+
102
+ max_dist = max(view_distance, buildings_in_buffer_points.distance(point_from).max())
103
+ polygons = []
104
+ buildings_lines_in_buffer = gpd.GeoDataFrame(geometry=buildings_lines_in_buffer, crs=obstacles.crs).reset_index()
105
+ logger.debug("Calculation vis polygon")
106
+ while not buildings_lines_in_buffer.empty:
107
+ gdf_sindex = buildings_lines_in_buffer.sindex
108
+ # TODO check if 2 walls are nearest and use the widest angle between points
109
+ nearest_wall_sind = gdf_sindex.nearest(point_from, return_all=False, max_distance=max_dist)
110
+ nearest_wall = buildings_lines_in_buffer.loc[nearest_wall_sind[1]].iloc[0]
111
+ wall_points = [Point(coords) for coords in nearest_wall.geometry.coords]
112
+
113
+ # Calculate angles and sort by angle
114
+ points_with_angle = sorted(
115
+ [(pt, math.atan2(pt.y - point_from.y, pt.x - point_from.x)) for pt in wall_points], key=lambda x: x[1]
116
+ )
117
+ delta_angle = 2 * math.pi + points_with_angle[0][1] - points_with_angle[-1][1]
118
+ if round(delta_angle, 10) == round(math.pi, 10):
119
+ wall_b_centroid = obstacles_in_buffer.loc[nearest_wall["index"]].centroid
120
+ p1 = get_point_from_a_thorough_b(point_from, points_with_angle[0][0], max_dist)
121
+ p2 = get_point_from_a_thorough_b(point_from, points_with_angle[1][0], max_dist)
122
+ polygon = LineString([p1, p2])
123
+ polygon = polygon.buffer(
124
+ distance=max_dist * point_side_of_line(polygon, wall_b_centroid), single_sided=True
125
+ )
126
+ else:
127
+ if delta_angle > math.pi:
128
+ delta_angle = 2 * math.pi - delta_angle
129
+ a = math.sqrt((max_dist**2) * (1 + (math.tan(delta_angle / 2) ** 2)))
130
+ p1 = get_point_from_a_thorough_b(point_from, points_with_angle[0][0], a)
131
+ p2 = get_point_from_a_thorough_b(point_from, points_with_angle[-1][0], a)
132
+ polygon = Polygon([points_with_angle[0][0], p1, p2, points_with_angle[1][0]])
133
+
134
+ polygons.append(polygon)
135
+ buildings_lines_in_buffer.drop(nearest_wall_sind[1], inplace=True)
136
+
137
+ if not polygon.is_valid or polygon.area < 1:
138
+ buildings_lines_in_buffer.reset_index(drop=True, inplace=True)
139
+ continue
140
+
141
+ lines_to_kick = buildings_lines_in_buffer.within(polygon)
142
+ buildings_lines_in_buffer = buildings_lines_in_buffer.loc[~lines_to_kick]
143
+ buildings_lines_in_buffer.reset_index(drop=True, inplace=True)
144
+ logger.debug("Done calculating!")
145
+ res = point_buffer.difference(unary_union(polygons + obstacles_in_buffer.to_list()))
146
+
147
+ if isinstance(res, MultiPolygon):
148
+ res = list(res.geoms)
149
+ for polygon in res:
150
+ if polygon.intersects(point_from):
151
+ res = polygon
152
+ break
153
+
154
+ if return_gdf:
155
+ res = gpd.GeoDataFrame(geometry=[res], crs=local_crs).to_crs(original_crs)
156
+
157
+ if return_max_view_dist:
158
+ return res, find_furthest_point(point_from, res)
159
+ return res
160
+
161
+
162
+ def get_visibility(
163
+ point_from: Point | gpd.GeoDataFrame, obstacles: gpd.GeoDataFrame, view_distance: float, resolution: int = 32
164
+ ) -> Polygon | gpd.GeoDataFrame:
165
+ """
166
+ Function to get a quick estimate of visibility from a given point to buildings within a given distance.
167
+
168
+ Parameters:
169
+ point_from (Point | gpd.GeoDataFrame):
170
+ The point or GeoDataFrame with 1 point from which the line of sight is drawn.
171
+ If Point is provided it should be in the same crs as obstacles.
172
+ obstacles (gpd.GeoDataFrame):
173
+ A GeoDataFrame containing the geometry of the buildings.
174
+ view_distance (float):
175
+ The distance of view from the point.
176
+ resolution (int) :
177
+ Buffer resolution for more accuracy (may give result slower)
178
+
179
+ Returns:
180
+ (Polygon | gpd.GeoDataFrame):
181
+ A polygon representing the area of visibility from the given point.
182
+ if point_from was a GeoDataFrame, return GeoDataFrame with one feature, else Polygon.
183
+
184
+ Notes:
185
+ This function provides a quicker but less accurate result compared to `get_visibility_accurate()`.
186
+ If accuracy is important, consider using `get_visibility_accurate()` instead.
187
+ """
188
+ return_gdf = False
189
+ if isinstance(point_from, gpd.GeoDataFrame):
190
+ original_crs = point_from.crs
191
+ return_gdf = True
192
+ if len(obstacles) > 0:
193
+ local_crs = obstacles.estimate_utm_crs()
194
+ else:
195
+ local_crs = point_from.estimate_utm_crs()
196
+ obstacles = obstacles.to_crs(local_crs)
197
+ point_from = point_from.to_crs(local_crs)
198
+ if len(point_from) > 1:
199
+ logger.warning(
200
+ f"This method processes only single point. The GeoDataFrame contains {len(point_from)} points - "
201
+ "only the first geometry will be used for isochrone calculation. "
202
+ )
203
+ point_from = point_from.iloc[0].geometry
204
+ else:
205
+ obstacles = obstacles.copy()
206
+ point_buffer = point_from.buffer(view_distance, resolution=resolution)
207
+ s = obstacles.intersects(point_buffer)
208
+ buildings_in_buffer = obstacles.loc[s[s].index].reset_index(drop=True)
209
+ buffer_exterior_ = list(point_buffer.exterior.coords)
210
+ line_geometry = [LineString([point_from, ext]) for ext in buffer_exterior_]
211
+ buffer_lines_gdf = gpd.GeoDataFrame(geometry=line_geometry)
212
+ united_buildings = buildings_in_buffer.union_all()
213
+ if united_buildings:
214
+ splited_lines = buffer_lines_gdf["geometry"].apply(lambda x: x.difference(united_buildings))
215
+ else:
216
+ splited_lines = buffer_lines_gdf["geometry"]
217
+
218
+ splited_lines_gdf = gpd.GeoDataFrame(geometry=splited_lines).explode(index_parts=True)
219
+ splited_lines_list = []
220
+
221
+ for _, v in splited_lines_gdf.groupby(level=0):
222
+ splited_lines_list.append(v.iloc[0]["geometry"].coords[-1])
223
+ circuit = Polygon(splited_lines_list)
224
+ if united_buildings:
225
+ circuit = circuit.difference(united_buildings)
226
+
227
+ if return_gdf:
228
+ circuit = gpd.GeoDataFrame(geometry=[circuit], crs=local_crs).to_crs(original_crs)
229
+ return circuit
230
+
231
+
232
+ def get_visibilities_from_points(
233
+ points: gpd.GeoDataFrame,
234
+ obstacles: gpd.GeoDataFrame,
235
+ view_distance: int,
236
+ sectors_n=None,
237
+ max_workers: int = cpu_count(),
238
+ ) -> list[Polygon]:
239
+ """
240
+ Calculate visibility polygons from a set of points considering obstacles within a specified view distance.
241
+
242
+ Parameters:
243
+ points (gpd.GeoDataFrame):
244
+ GeoDataFrame containing the points from which visibility is calculated.
245
+ obstacles (gpd.GeoDataFrame):
246
+ GeoDataFrame containing the obstacles that block visibility.
247
+ view_distance (int):
248
+ The maximum distance from each point within which visibility is calculated.
249
+ sectors_n (int, optional):
250
+ Number of sectors to divide the view into for more detailed visibility calculations. Defaults to None.
251
+ max_workers (int, optional):
252
+ Maximum workers in multiproccesing, multipocessing.cpu_count() by default.
253
+
254
+ Returns:
255
+ (list[Polygon]):
256
+ A list of visibility polygons for each input point.
257
+
258
+ Notes:
259
+ This function uses `get_visibility_accurate()` in multiprocessing way.
260
+
261
+ """
262
+ if points.crs != obstacles.crs:
263
+ raise ValueError(f"CRS mismatch, points crs:{points.crs} != obstacles crs:{obstacles.crs}")
264
+ if points.crs.is_geographic:
265
+ logger.warning("Points crs is geographic, it may produce invalid results")
266
+ # remove points inside polygons
267
+ joined = gpd.sjoin(points, obstacles, how="left", predicate="intersects")
268
+ points = joined[joined.index_right.isnull()]
269
+
270
+ # remove unused obstacles
271
+ points_view = points.geometry.buffer(view_distance).union_all()
272
+ s = obstacles.intersects(points_view)
273
+ buildings_in_buffer = obstacles.loc[s[s].index].reset_index(drop=True)
274
+
275
+ buildings_in_buffer.geometry = buildings_in_buffer.geometry.apply(
276
+ lambda geom: MultiPolygon([geom]) if isinstance(geom, Polygon) else geom
277
+ )
278
+ args = [(point, buildings_in_buffer, view_distance, sectors_n) for point in points.geometry]
279
+ all_visions = process_map(
280
+ _multiprocess_get_vis,
281
+ args,
282
+ chunksize=5,
283
+ desc="Calculating Visibility Catchment Area from each Point, it might take a while for a "
284
+ "big amount of points",
285
+ max_workers=max_workers,
286
+ )
287
+
288
+ # could return sectorized visions if sectors_n is set
289
+ return all_visions
290
+
291
+
292
+ def calculate_visibility_catchment_area(
293
+ points: gpd.GeoDataFrame, obstacles: gpd.GeoDataFrame, view_distance: int | float, max_workers: int = cpu_count()
294
+ ) -> gpd.GeoDataFrame: # pragma: no cover
295
+ """
296
+ Calculate visibility catchment areas for a large urban area based on given points and obstacles.
297
+ This function is designed to work with at least 1000 points spaced 10-20 meters apart for optimal results.
298
+ Points can be generated using a road graph.
299
+
300
+ Parameters:
301
+ points (gpd.GeoDataFrame): GeoDataFrame containing the points from which visibility is calculated.
302
+ obstacles (gpd.GeoDataFrame): GeoDataFrame containing the obstacles that block visibility.
303
+ view_distance (int | float): The maximum distance from each point within which visibility is calculated.
304
+ max_workers (int): Maximum workers in multiproccesing, multipocessing.cpu_count() by default.
305
+
306
+ Returns:
307
+ (gpd.GeoDataFrame): GeoDataFrame containing the calculated visibility catchment areas.
308
+ """
309
+
310
+ def filter_geoms(x):
311
+ if x.geom_type == "GeometryCollection":
312
+ return MultiPolygon([y for y in x.geoms if y.geom_type in ["Polygon", "MultiPolygon"]])
313
+ return x
314
+
315
+ def calc_group_factor(x):
316
+ # pylint: disable-next=redefined-outer-name,reimported,import-outside-toplevel
317
+ import numpy as np
318
+
319
+ return np.mean(x.new_ratio) * x.count_n
320
+
321
+ def unary_union_groups(x):
322
+ # pylint: disable-next=redefined-outer-name,reimported,import-outside-toplevel
323
+ from shapely import MultiPolygon
324
+
325
+ # pylint: disable-next=redefined-outer-name,reimported,import-outside-toplevel
326
+ from shapely.ops import unary_union
327
+
328
+ return unary_union(MultiPolygon(list(x["geometry"])).buffer(0))
329
+
330
+ pandarallel.initialize(progress_bar=True, verbose=0)
331
+
332
+ local_crs = obstacles.estimate_utm_crs()
333
+ obstacles = obstacles.to_crs(local_crs)
334
+ points = points.to_crs(local_crs)
335
+
336
+ sectors_n = 12
337
+ logger.info("Calculating Visibility Catchment Area from each point")
338
+ all_visions_sectorized = get_visibilities_from_points(points, obstacles, view_distance, sectors_n, max_workers)
339
+ all_visions_sectorized = gpd.GeoDataFrame(
340
+ geometry=[item for sublist in all_visions_sectorized for item in sublist], crs=local_crs
341
+ )
342
+ logger.info("Calculating non-vision part...")
343
+ all_visions_unary = all_visions_sectorized.union_all()
344
+ convex = all_visions_unary.convex_hull
345
+ dif = convex.difference(all_visions_unary)
346
+
347
+ del convex, all_visions_unary
348
+
349
+ buf_area = (math.pi * view_distance**2) / sectors_n
350
+ all_visions_sectorized["ratio"] = all_visions_sectorized.area / buf_area
351
+ all_visions_sectorized["ratio"] = min_max_normalization(
352
+ all_visions_sectorized["ratio"].values, new_min=1, new_max=10
353
+ )
354
+ groups = all_visions_sectorized.sample(frac=1).groupby(all_visions_sectorized.index // 6000)
355
+ groups = [group for _, group in groups]
356
+
357
+ del all_visions_sectorized
358
+
359
+ groups_result = process_map(
360
+ _process_group,
361
+ groups,
362
+ desc="Counting intersections in each group...",
363
+ max_workers=max_workers,
364
+ )
365
+ logger.info("Calculating all groups intersection...")
366
+ all_in = combine_geometry(gpd.GeoDataFrame(data=pd.concat(groups_result), geometry="geometry", crs=local_crs))
367
+
368
+ del groups_result
369
+
370
+ all_in["count_n"] = all_in["index_right"].apply(len)
371
+
372
+ logger.info("Calculating intersection's parameters")
373
+ all_in["factor"] = all_in.parallel_apply(calc_group_factor, axis=1)
374
+ threshold = all_in["factor"].quantile(0.3)
375
+ all_in = all_in[all_in["factor"] > threshold]
376
+
377
+ all_in["factor_normalized"] = np.round(
378
+ min_max_normalization(np.sqrt(all_in["factor"].values), new_min=1, new_max=5)
379
+ ).astype(int)
380
+ logger.info("Calculating normalized groups geometry...")
381
+ all_in = all_in.groupby("factor_normalized").parallel_apply(unary_union_groups).reset_index()
382
+ all_in = gpd.GeoDataFrame(data=all_in.rename(columns={0: "geometry"}), geometry="geometry", crs=32636)
383
+
384
+ all_in = all_in.explode(index_parts=True).reset_index(drop=True)
385
+ all_in["area"] = all_in.area
386
+ threshold = all_in["area"].quantile(0.9)
387
+ all_in = all_in[all_in["area"] > threshold]
388
+ all_in = all_in.groupby("factor_normalized").apply(unary_union_groups).reset_index()
389
+ all_in = gpd.GeoDataFrame(data=all_in.rename(columns={0: "geometry"}), geometry="geometry", crs=32636)
390
+
391
+ all_in.geometry = all_in.geometry.buffer(20).buffer(-20).difference(dif)
392
+
393
+ all_in.sort_values(by="factor_normalized", ascending=False, inplace=True)
394
+ all_in.reset_index(drop=True, inplace=True)
395
+ logger.info("Smoothing normalized groups geometry...")
396
+ for ind, row in all_in.iloc[:-1].iterrows():
397
+ for ind2 in range(ind + 1, len(all_in)):
398
+ current_geometry = all_in.at[ind2, "geometry"]
399
+ all_in.at[ind2, "geometry"] = current_geometry.difference(row.geometry)
400
+ all_in["geometry"] = all_in["geometry"].apply(filter_geoms)
401
+
402
+ all_in = all_in.explode(index_parts=True)
403
+ logger.info("Done!")
404
+ return all_in
405
+
406
+
407
+ def _multiprocess_get_vis(args): # pragma: no cover
408
+ point, buildings, view_distance, sectors_n = args
409
+ result = get_visibility_accurate(point, buildings, view_distance)
410
+
411
+ if sectors_n is not None:
412
+ sectors = []
413
+
414
+ cx, cy = point.x, point.y
415
+
416
+ angle_increment = 2 * math.pi / sectors_n
417
+ view_distance = math.sqrt((view_distance**2) * (1 + (math.tan(angle_increment / 2) ** 2)))
418
+ for i in range(sectors_n):
419
+ angle1 = i * angle_increment
420
+ angle2 = (i + 1) * angle_increment
421
+
422
+ x1, y1 = cx + view_distance * math.cos(angle1), cy + view_distance * math.sin(angle1)
423
+ x2, y2 = cx + view_distance * math.cos(angle2), cy + view_distance * math.sin(angle2)
424
+
425
+ sector_triangle = Polygon([point, (x1, y1), (x2, y2)])
426
+ sector = result.intersection(sector_triangle)
427
+
428
+ if not sector.is_empty:
429
+ sectors.append(sector)
430
+ result = sectors
431
+ return result
432
+
433
+
434
+ def _process_group(group): # pragma: no cover
435
+ geom = group
436
+ combined_geometry = combine_geometry(geom)
437
+ combined_geometry.drop(columns=["index", "index_right"], inplace=True)
438
+ combined_geometry["count_n"] = combined_geometry["ratio"].apply(len)
439
+ combined_geometry["new_ratio"] = combined_geometry.apply(
440
+ lambda x: np.power(np.prod(x.ratio), 1 / x.count_n) * x.count_n, axis=1
441
+ )
442
+
443
+ threshold = combined_geometry["new_ratio"].quantile(0.25)
444
+ combined_geometry = combined_geometry[combined_geometry["new_ratio"] > threshold]
445
+
446
+ combined_geometry["new_ratio_normalized"] = min_max_normalization(
447
+ combined_geometry["new_ratio"].values, new_min=1, new_max=10
448
+ )
449
+
450
+ combined_geometry["new_ratio_normalized"] = np.round(combined_geometry["new_ratio_normalized"]).astype(int)
451
+
452
+ result_union = (
453
+ combined_geometry.groupby("new_ratio_normalized")
454
+ .agg({"geometry": lambda x: unary_union(MultiPolygon(list(x)).buffer(0))})
455
+ .reset_index(drop=True)
456
+ )
457
+ result_union.set_geometry("geometry", inplace=True)
458
+ result_union.set_crs(geom.crs, inplace=True)
459
+
460
+ result_union = result_union.explode("geometry", index_parts=False).reset_index(drop=True)
461
+
462
+ representative_points = combined_geometry.copy()
463
+ representative_points["geometry"] = representative_points["geometry"].representative_point()
464
+
465
+ joined = gpd.sjoin(result_union, representative_points, how="inner", predicate="contains").reset_index()
466
+ joined = joined.groupby("index").agg({"geometry": "first", "new_ratio": lambda x: np.mean(list(x))})
467
+
468
+ joined.set_geometry("geometry", inplace=True)
469
+ joined.set_crs(geom.crs, inplace=True)
470
+ return joined