Coverage for censusdis/impl/geometry.py: 97%

38 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2025-04-03 05:39 +0000

1# Copyright (c) 2023 Darren Erik Vengroff 

2"""Utilities for geometric operations.""" 

3 

4import math 

5from typing import Optional, TypeVar, Union 

6 

7import geopandas as gpd 

8from shapely import MultiPolygon, Polygon 

9 

10 

11def isoperimetric_quotient( 

12 geo: Union[gpd.GeoDataFrame, gpd.GeoSeries, Polygon], 

13) -> float: 

14 """ 

15 Compute the isoperimetric quotient of a shape or collection of shapes. 

16 

17 The isoperimetric quotient is a measure of the compactness of a polygom. 

18 It is defines as the ratio of the area of the shape to the area of a 

19 circle with the same perimeter. It ranges from zero, when the polygon 

20 has zero area, to 1.0 when the polygon approximates a circle. It is not 

21 possible for a shape that is not a circle to have more area than a circle 

22 with the same permimeter. 

23 

24 For more info, see https://en.wikipedia.org/wiki/Compactness_measure_of_a_shape 

25 and https://en.wikipedia.org/wiki/Isoperimetric_inequality. 

26 

27 This is also equivalent to the Polsby–Popper test. See 

28 https://en.wikipedia.org/wiki/Polsby%E2%80%93Popper_test. 

29 

30 Parameters 

31 ---------- 

32 geo: 

33 The geography or a series or dataframe of them. 

34 

35 Returns 

36 ------- 

37 The isoperimetric_quotient (Polsby–Popper test) value or values 

38 between 0 and 1. 

39 """ 

40 area = geo.area 

41 length = geo.exterior.length 

42 

43 q = 4 * math.pi * area / (length * length) 

44 

45 return q 

46 

47 

48def drop_polygon_if_sliver( 

49 polygon: Polygon, threshold: float = 0.01 

50) -> Optional[Polygon]: 

51 """ 

52 Drop a polygon if it is a sliver. 

53 

54 Parameters 

55 ---------- 

56 polygon 

57 The polygon to check. 

58 threshold 

59 Threshold of sliveryness (isoperimetric quotient). 

60 

61 Returns 

62 ------- 

63 `polygon` if not a sliver or `None` if a sliver. 

64 """ 

65 if isoperimetric_quotient(polygon) < threshold: 

66 return None 

67 

68 return polygon 

69 

70 

71def drop_slivers_multi_polygon( 

72 multi_polygon: Union[Polygon, MultiPolygon], threshold: float = 0.01 

73) -> Optional[Union[Polygon, MultiPolygon]]: 

74 """ 

75 Drop all the sliver polygons from a multi-polygon. 

76 

77 Parameters 

78 ---------- 

79 multi_polygon 

80 A `MultiPolygon`. 

81 threshold 

82 The isopermimetric threshold below which polygons are considered 

83 slivers. 

84 

85 Returns 

86 ------- 

87 A shape (either a `Polygon` or `MultiPolygon` containing all non-sliver 

88 polygones. If there aren't any, `None` is returned. 

89 """ 

90 remaining_polygons = [ 

91 poly 

92 for poly in multi_polygon.geoms 

93 if isoperimetric_quotient(poly) >= threshold 

94 ] 

95 

96 if len(remaining_polygons) == 0: 

97 return None 

98 elif len(remaining_polygons) == 1: 

99 return remaining_polygons[0] 

100 else: 

101 return MultiPolygon(remaining_polygons) 

102 

103 

104def drop_slivers_from_geo_series( 

105 gs_geo: gpd.GeoSeries, threshold: float = 0.01 

106) -> gpd.GeoSeries: 

107 """ 

108 Drop all slivers from the geometries in a `GeoSeries`. 

109 

110 Parameters 

111 ---------- 

112 gs_geo 

113 The original `GeoSeries`. 

114 threshold 

115 The isoperimetric quotient threshold. 

116 

117 Returns 

118 ------- 

119 The series with all slivers removed. 

120 """ 

121 return gs_geo.map( 

122 lambda s: ( 

123 drop_slivers_multi_polygon(s, threshold) 

124 if isinstance(s, MultiPolygon) 

125 else drop_polygon_if_sliver(s, threshold) if isinstance(s, Polygon) else s 

126 ) 

127 ) 

128 

129 

130def drop_slivers_from_gdf( 

131 gdf_geo: gpd.GeoDataFrame, threshold: float = 0.01 

132) -> gpd.GeoDataFrame: 

133 """ 

134 Drop all slivers from the geometries in a `GeoDataFrame`. 

135 

136 Parameters 

137 ---------- 

138 gdf_geo 

139 The original `GeoDataFrame`. 

140 threshold 

141 The isoperimetric quotient threshold. 

142 

143 Returns 

144 ------- 

145 The geo data frame with all slivers removed. 

146 """ 

147 new_geometry = drop_slivers_from_geo_series(gdf_geo.geometry, threshold=threshold) 

148 

149 gdf_result = gpd.GeoDataFrame(gdf_geo, geometry=new_geometry) 

150 

151 return gdf_result 

152 

153 

154T = TypeVar("T", Polygon, MultiPolygon, gpd.GeoSeries, gpd.GeoDataFrame) 

155 

156 

157def drop_slivers( 

158 geo: T, 

159 threshold: float = 0.01, 

160) -> Union[None, T]: 

161 """ 

162 Drop slivers from an arbitrary geometry or collection of geometries. 

163 

164 Accepts `MultiPolygon`, `Polygon`, `gpd.GeoSeries` or `gpd.GeoDataFrame` 

165 

166 Parameters 

167 ---------- 

168 geo 

169 the geography. 

170 threshold 

171 the isoperimetric threshold. 

172 

173 Returns 

174 ------- 

175 The geometry with slivers dropped. 

176 """ 

177 if isinstance(geo, MultiPolygon): 

178 return drop_slivers_multi_polygon(geo, threshold=threshold) 

179 elif isinstance(geo, Polygon): 

180 return drop_slivers_multi_polygon(MultiPolygon((geo,)), threshold=threshold) 

181 if isinstance(geo, gpd.GeoSeries): 

182 return drop_slivers_from_geo_series(geo, threshold=threshold) 

183 if isinstance(geo, gpd.GeoDataFrame): 

184 return drop_slivers_from_gdf(geo, threshold=threshold) 

185 

186 raise ValueError( 

187 f"Unable to drop slivers from a {type(geo)}. " 

188 "Must be a GeoDataFrame, GeoSeries, and MultiPolygones only." 

189 )