1import numpy 2import pandas 3 4try: 5 import pygeos 6except (ImportError, ModuleNotFoundError): 7 pass # gets handled at the _cast level. 8 9from .crand import njit, prange 10 11 12# -------------------- UTILITIES --------------------# 13def _cast(collection): 14 """ 15 Cast a collection to a pygeos geometry array. 16 """ 17 try: 18 import pygeos, geopandas 19 except (ImportError, ModuleNotFoundError) as exception: 20 raise type(exception)("pygeos and geopandas are required for shape statistics.") 21 22 if isinstance(collection, (geopandas.GeoSeries, geopandas.GeoDataFrame)): 23 return collection.geometry.values.data.squeeze() 24 elif pygeos.is_geometry(collection).all(): 25 if isinstance(collection, (numpy.ndarray, list)): 26 return numpy.asarray(collection) 27 else: 28 return numpy.array([collection]) 29 elif isinstance(collection, (numpy.ndarray, list)): 30 return pygeos.from_shapely(collection).squeeze() 31 else: 32 return numpy.array([pygeos.from_shapely(collection)]) 33 34 35def get_angles(collection, return_indices=False): 36 """ 37 Get the angles pertaining to each vertex of a set of polygons. 38 This assumes the input are polygons. 39 40 Arguments 41 --------- 42 ga : pygeos geometry array 43 array of polygons/multipolygons 44 return_indices : bool (Default: False) 45 whether to return the indices relating each geometry to a polygon 46 47 Returns 48 ------- 49 angles between triples of points on each geometry, as well as the indices 50 relating angles to input geometries (if requested). 51 52 See the Notes for information on the shape of angles and indices. 53 54 Notes 55 ------- 56 If a geometry has n coordinates and k parts, the array will be n - k. 57 If each geometry has n_i coordinates, then let N be a vector storing 58 those counts (computed, for example, using pygeos.get_num_coordinates(ga)). 59 Likewise, let K be a vector storing the number of parts each geometry has, k_i 60 (computed, for example, using pygeos.get_num_geometries(ga)) 61 62 Then, the output is of shape (N - K).sum() 63 64 """ 65 ga = _cast(collection) 66 exploded = pygeos.get_parts(ga) 67 coords = pygeos.get_coordinates(exploded) 68 n_coords_per_geom = pygeos.get_num_coordinates(exploded) 69 n_parts_per_geom = pygeos.get_num_geometries(exploded) 70 angles = numpy.asarray(_get_angles(coords, n_coords_per_geom)) 71 if return_indices: 72 return angles, numpy.repeat( 73 numpy.arange(len(ga)), 74 pygeos.get_num_coordinates(ga) - pygeos.get_num_geometries(ga), 75 ) 76 else: 77 return angles 78 79 80@njit 81def _get_angles(points, n_coords_per_geom): 82 """ 83 Iterate over points in a set of geometries. 84 This assumes that the input geometries are simple, not multi! 85 """ 86 # Start at the first point of the first geometry 87 offset = int(0) 88 start = points[0] 89 on_geom = 0 90 on_coord = 0 91 result = [] 92 n_points = len(points) 93 while True: 94 # if we're on the last point before the closure point, 95 if on_coord == (n_coords_per_geom[on_geom] - 1): 96 # set the offset to start on the first point of the next geometry 97 offset += on_coord + 1 98 on_geom += 1 99 on_coord = 0 100 # if we're now done with all geometries, exit 101 if on_geom == len(n_coords_per_geom): 102 break 103 else: 104 # and continue to the next iteration. 105 continue 106 # construct the triple so that we wrap around and avoid the closure point 107 left_ix = offset + on_coord % (n_coords_per_geom[on_geom] - 1) 108 center_ix = offset + (on_coord + 1) % (n_coords_per_geom[on_geom] - 1) 109 right_ix = offset + (on_coord + 2) % (n_coords_per_geom[on_geom] - 1) 110 # grab the actual coordinates corresponding to the triple 111 left = points[left_ix] 112 center = points[center_ix] 113 right = points[right_ix] 114 # build the line segments originating at center 115 a = left - center 116 b = right - center 117 # compute the angle between the segments 118 angle = numpy.math.atan2(a[0] * b[1] - a[1] * b[0], numpy.dot(a, b)) 119 result.append(angle) 120 on_coord += 1 121 return result 122 123 124# -------------------- IDEAL SHAPE MEASURES -------------------- # 125 126 127def isoperimetric_quotient(collection): 128 """ 129 The Isoperimetric quotient, defined as the ratio of a polygon's area to the 130 area of the equi-perimeter circle. 131 132 Altman's PA_1 measure 133 134 Construction: 135 -------------- 136 let: 137 p_d = perimeter of polygon 138 a_d = area of polygon 139 140 a_c = area of the constructed circle 141 r = radius of constructed circle 142 143 then the relationship between the constructed radius and the polygon 144 perimeter is: 145 p_d = 2 \pi r 146 p_d / (2 \pi) = r 147 148 meaning the area of the circle can be expressed as: 149 a_c = \pi r^2 150 a_c = \pi (p_d / (2\pi))^2 151 152 implying finally that the IPQ is: 153 154 pp = (a_d) / (a_c) = (a_d) / ((p_d / (2*\pi))^2 * \pi) = (a_d) / (p_d**2 / (4\PI)) 155 """ 156 ga = _cast(collection) 157 return (4 * numpy.pi * pygeos.area(ga)) / (pygeos.measurement.length(ga) ** 2) 158 159 160def isoareal_quotient(collection): 161 """ 162 The Isoareal quotient, defined as the ratio of a polygon's perimeter to the 163 perimeter of the equi-areal circle 164 165 Altman's PA_3 measure, and proportional to the PA_4 measure 166 """ 167 ga = _cast(collection) 168 return ( 169 2 * numpy.pi * numpy.sqrt(pygeos.area(ga) / numpy.pi) 170 ) / pygeos.measurement.length(ga) 171 172 173def minimum_bounding_circle_ratio(collection): 174 """ 175 The Reock compactness measure, defined by the ratio of areas between the 176 minimum bounding/containing circle of a shape and the shape itself. 177 178 Measure A1 in Altman (1998), cited for Frolov (1974), but earlier from Reock 179 (1963) 180 """ 181 ga = _cast(collection) 182 mbc = pygeos.minimum_bounding_circle(ga) 183 return pygeos.area(ga) / pygeos.area(mbc) 184 185 186def radii_ratio(collection): 187 """ 188 The Flaherty & Crumplin (1992) index, OS_3 in Altman (1998). 189 190 The ratio of the radius of the equi-areal circle to the radius of the MBC 191 """ 192 ga = _cast(collection) 193 r_eac = numpy.sqrt(pygeos.area(ga) / numpy.pi) 194 r_mbc = pygeos.minimum_bounding_radius(ga) 195 return r_eac / r_mbc 196 197 198def diameter_ratio(collection, rotated=True): 199 """ 200 The Flaherty & Crumplin (1992) length-width measure, stated as measure LW_7 201 in Altman (1998). 202 203 It is given as the ratio between the minimum and maximum shape diameter. 204 """ 205 ga = _cast(collection) 206 if rotated: 207 box = pygeos.minimum_rotated_rectangle(ga) 208 coords = pygeos.get_coordinates(box) 209 a, b, c, d = (coords[0::5], coords[1::5], coords[2::5], coords[3::5]) 210 widths = numpy.sqrt(numpy.sum((a - b) ** 2, axis=1)) 211 heights = numpy.sqrt(numpy.sum((a - d) ** 2, axis=1)) 212 else: 213 box = pygeos.bounds(ga) 214 (xmin, xmax), (ymin, ymax) = box[:, [0, 2]].T, box[:, [1, 3]].T 215 widths, heights = numpy.abs(xmax - xmin), numpy.abs(ymax - ymin) 216 return numpy.minimum(widths, heights) / numpy.maximum(widths, heights) 217 218 219def length_width_diff(collection): 220 """ 221 The Eig & Seitzinger (1981) shape measure, defined as: 222 223 L - W 224 225 Where L is the maximal east-west extent and W is the maximal north-south 226 extent. 227 228 Defined as measure LW_5 in Altman (1998) 229 """ 230 ga = _cast(collection) 231 box = pygeos.bounds(ga) 232 (xmin, xmax), (ymin, ymax) = box[:, [0, 2]].T, box[:, [1, 3]].T 233 width, height = numpy.abs(xmax - xmin), numpy.abs(ymax - ymin) 234 return width - height 235 236 237def boundary_amplitude(collection): 238 """ 239 The boundary amplitude (adapted from Wang & Huang (2012)) is the 240 length of the boundary of the convex hull divided by the length of the 241 boundary of the original shape. 242 243 Notes 244 ----- 245 246 This is inverted from Wang & Huang (2012) in order to provide a value 247 between zero and one, like many of the other ideal shape-based indices. 248 """ 249 ga = _cast(collection) 250 return pygeos.measurement.length( 251 pygeos.convex_hull(ga) 252 ) / pygeos.measurement.length(ga) 253 254 255def convex_hull_ratio(collection): 256 """ 257 ratio of the area of the convex hull to the area of the shape itself 258 259 Altman's A_3 measure, from Neimi et al 1991. 260 """ 261 ga = _cast(collection) 262 return pygeos.area(ga) / pygeos.area(pygeos.convex_hull(ga)) 263 264 265def fractal_dimension(collection, support="hex"): 266 """ 267 The fractal dimension of the boundary of a shape, assuming a given spatial support for the geometries. 268 269 Note that this derivation assumes a specific ideal spatial support for the polygon, and is thus may not return valid results for complex or highly irregular geometries. 270 """ 271 ga = _cast(collection) 272 P = pygeos.measurement.length(ga) 273 A = pygeos.area(ga) 274 if support == "hex": 275 return 2 * numpy.log(P / 6) / numpy.log(A / (3 * numpy.sin(numpy.pi / 3))) 276 elif support == "square": 277 return 2 * numpy.log(P / 4) / numpy.log(A) 278 elif support == "circle": 279 return 2 * numpy.log(P / (2 * numpy.pi)) / numpy.log(A / numpy.pi) 280 else: 281 raise ValueError( 282 f"The support argument must be one of 'hex', 'circle', or 'square', but {support} was provided." 283 ) 284 285 286def squareness(collection): 287 """ 288 Measures how different is a given shape from an equi-areal square 289 290 The index is close to 0 for highly irregular shapes and to 1.3 for circular shapes. 291 It equals 1 for squares. 292 293 .. math:: 294 \\begin{equation} 295 \\frac{ 296 \\sqrt{A}}{P^{2}} 297 \\times 298 \\frac{\\left(4 \\sqrt{\\left.A\\right)}^{2}\\right.}{\\sqrt{A}} 299 = 300 \\frac{\\left(4 \\sqrt{A}\\right)^{2}}{P{ }^{2}} 301 = 302 \\left(\\frac{4 \\sqrt{A}}{P}\\right)^{2} 303 \\end{equation} 304 305 where :math:`A` is the area and :math:`P` is the perimeter. 306 307 Notes 308 ----- 309 Implementation follows :cite:`basaraner2017`. 310 311 """ 312 ga = _cast(collection) 313 return ((numpy.sqrt(pygeos.area(ga)) * 4) / pygeos.length(ga)) ** 2 314 315 316def rectangularity(collection): 317 """ 318 Ratio of the area of the shape to the area of its minimum bounding rotated rectangle 319 320 Reveals a polygon’s degree of being curved inward. 321 322 .. math:: 323 \\frac{A}{A_{MBR}} 324 325 where :math:`A` is the area and :math:`A_{MBR}` is the area of minimum bounding 326 rotated rectangle. 327 328 Notes 329 ----- 330 Implementation follows :cite:`basaraner2017`. 331 """ 332 ga = _cast(collection) 333 return pygeos.area(ga) / pygeos.area(pygeos.minimum_rotated_rectangle(ga)) 334 335 336def shape_index(collection): 337 """ 338 Schumm’s shape index (Schumm (1956) in MacEachren 1985) 339 340 .. math:: 341 {\\sqrt{{A} \\over {\\pi}}} \\over {R} 342 343 where :math:`A` is the area and :math:`R` is the radius of the minimum bounding 344 circle. 345 346 Notes 347 ----- 348 Implementation follows :cite:`maceachren1985compactness`. 349 350 """ 351 ga = _cast(collection) 352 return numpy.sqrt(pygeos.area(ga) / numpy.pi) / pygeos.minimum_bounding_radius(ga) 353 354 355def equivalent_rectangular_index(collection): 356 """ 357 Deviation of a polygon from an equivalent rectangle 358 359 .. math:: 360 \\frac{\\sqrt{A}}{A_{MBR}} 361 \\times 362 \\frac{P_{MBR}}{P} 363 364 where :math:`A` is the area, :math:`A_{MBR}` is the area of minimum bounding 365 rotated rectangle, :math:`P` is the perimeter, :math:`P_{MBR}` is the perimeter 366 of minimum bounding rotated rectangle. 367 368 Notes 369 ----- 370 Implementation follows :cite:`basaraner2017`. 371 """ 372 ga = _cast(collection) 373 box = pygeos.minimum_rotated_rectangle(ga) 374 return numpy.sqrt(pygeos.area(ga) / pygeos.area(box)) * ( 375 pygeos.length(box) / pygeos.length(ga) 376 ) 377 378 379# -------------------- VOLMETRIC MEASURES ------------------- # 380 381 382def form_factor(collection, height): 383 """ 384 Computes volumetric compactness 385 386 .. math:: 387 \\frac{A}{(A \\times H)^{\\frac{2}{3}}} 388 389 where :math:`A` is the area and :math:`H` is polygon's 390 height. 391 392 Notes 393 ----- 394 Implementation follows :cite:`bourdic2012`. 395 """ 396 ga = _cast(collection) 397 A = pygeos.area(ga) 398 V = A * height 399 zeros = V == 0 400 res = numpy.zeros(len(ga)) 401 res[~zeros] = A[~zeros] / (V[~zeros] ** (2 / 3)) 402 return res 403 404 405# -------------------- INERTIAL MEASURES -------------------- # 406 407 408def moment_of_inertia(collection): 409 """ 410 Computes the moment of inertia of the polygon. 411 412 This treats each boundary point as a point-mass of 1. 413 414 Thus, for constant unit mass at each boundary point, 415 the MoI of this pointcloud is 416 417 \sum_i d_{i,c}^2 418 419 where c is the centroid of the polygon 420 421 Altman's OS_1 measure, cited in Boyce and Clark (1964), also used in Weaver 422 and Hess (1963). 423 """ 424 ga = _cast(collection) 425 coords = pygeos.get_coordinates(ga) 426 geom_ixs = numpy.repeat(numpy.arange(len(ga)), pygeos.get_num_coordinates(ga)) 427 centroids = pygeos.get_coordinates(pygeos.centroid(ga))[geom_ixs] 428 squared_euclidean = numpy.sum((coords - centroids) ** 2, axis=1) 429 dists = ( 430 pandas.DataFrame.from_dict(dict(d2=squared_euclidean, geom_ix=geom_ixs)) 431 .groupby("geom_ix") 432 .d2.sum() 433 ).values 434 return pygeos.area(ga) / numpy.sqrt(2 * dists) 435 436 437def moa_ratio(collection): 438 """ 439 Computes the ratio of the second moment of area (like Li et al (2013)) to 440 the moment of area of a circle with the same area. 441 """ 442 ga = _cast(collection) 443 r = pygeos.measurement.length(ga) / (2 * numpy.pi) 444 return (numpy.pi * 0.5 * r ** 4) / second_areal_moment(ga) 445 446 447def nmi(collection): 448 """ 449 Computes the Normalized Moment of Inertia from Li et al (2013), recognizing 450 that it is the relationship between the area of a shape squared divided by 451 its second moment of area. 452 """ 453 ga = _cast(collection) 454 return pygeos.area(ga) ** 2 / (2 * second_areal_moment(ga) * numpy.pi) 455 456 457def second_areal_moment(collection): 458 """ 459 Using equation listed on en.wikipedia.org/Second_Moment_of_area, the second 460 moment of area is actually the cross-moment of area between the X and Y 461 dimensions: 462 463 I_xy = (1/24)\sum^{i=N}^{i=1} (x_iy_{i+1} + 2*x_iy_i + 2*x_{i+1}y_{i+1} + 464 x_{i+1}y_i)(x_iy_i - x_{i+1}y_i) 465 466 where x_i, y_i is the current point and x_{i+1}, y_{i+1} is the next point, 467 and where x_{n+1} = x_1, y_{n+1} = 1. 468 469 This relation is known as the: 470 - second moment of area 471 - moment of inertia of plane area 472 - area moment of inertia 473 - second area moment 474 475 and is *not* the mass moment of inertia, a property of the distribution of 476 mass around a shape. 477 """ 478 ga = _cast(collection) 479 result = numpy.zeros(len(ga)) 480 n_holes_per_geom = pygeos.get_num_interior_rings(ga) 481 for i, geometry in enumerate(ga): 482 n_holes = n_holes_per_geom[i] 483 for hole_ix in range(n_holes): 484 hole = pygeos.get_coordinates(pygeos.get_interior_ring(ga, hole_ix)) 485 result[i] -= _second_moa_ring(hole) 486 n_parts = pygeos.get_num_geometries(geometry) 487 for part in pygeos.get_parts(geometry): 488 result[i] += _second_moa_ring(pygeos.get_coordinates(part)) 489 # must divide everything by 24 and flip if polygon is clockwise. 490 signflip = numpy.array([-1, 1])[pygeos.is_ccw(ga).astype(int)] 491 return result * (1 / 24) * signflip 492 493 494@njit 495def _second_moa_ring(points): 496 """ 497 implementation of the moment of area for a single ring 498 """ 499 moi = 0 500 for i in prange(len(points[:-1])): 501 x_tail, y_tail = points[i] 502 x_head, y_head = points[i + 1] 503 moi += (x_tail * y_head - x_head * y_tail) * ( 504 x_tail * y_head 505 + 2 * x_tail * y_tail 506 + 2 * x_head * y_head 507 + x_head * y_tail 508 ) 509 return moi 510 511 512# -------------------- OTHER MEASURES -------------------- # 513 514 515def reflexive_angle_ratio(collection): 516 """ 517 The Taylor reflexive angle index, measure OS_4 in Altman (1998) 518 519 (N-R)/(N+R), the difference in number between non-reflexive angles and 520 reflexive angles in a polygon, divided by the number of angles in the 521 polygon in general. 522 """ 523 angles, geom_indices = get_angles(collection, return_indices=True) 524 return ( 525 pandas.DataFrame.from_dict(dict(is_reflex=angles < 0, geom_ix=geom_indices)) 526 .groupby("geom_ix") 527 .is_reflex.mean() 528 .values 529 ) 530