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