# Source code for pointpats.centrography

```"""
Centrographic measures for point patterns

TODO

- testing
- documentation

"""

__author__ = "Serge Rey sjsrey@gmail.com"

__all__ = [
"mbr",
"hull",
"mean_center",
"weighted_mean_center",
"manhattan_median",
"std_distance",
"euclidean_median",
"ellipse",
"minimum_rotated_rectangle",
"minimum_bounding_rectangle",
"skyum",
"dtot",
"_circle",
]

import sys
import numpy as np
import warnings
import copy

from math import pi as PI
from scipy.spatial import ConvexHull
from libpysal.cg import get_angle_between, Ray, is_clockwise
from scipy.spatial import distance as dist
from scipy.optimize import minimize

not_clockwise = lambda x: not is_clockwise(x)

MAXD = sys.float_info.max
MIND = sys.float_info.min

[docs]
def minimum_bounding_rectangle(points):
"""
Find minimum bounding rectangle of a point array.

Parameters
----------
points : arraylike
(n,2), (x,y) coordinates of a series of event points.

Returns
-------
min_x  : float
leftmost value of the vertices of minimum bounding rectangle.
min_y  : float
downmost value of the vertices of minimum bounding rectangle.
max_x  : float
rightmost value of the vertices of minimum bounding rectangle.
max_y  : float
upmost value of the vertices of minimum bounding rectangle.

"""
points = np.asarray(points)
min_x = min_y = MAXD
max_x = max_y = MIND
x, y = zip(*points)
min_x = min(x)
min_y = min(y)
max_x = max(x)
max_y = max(y)
return min_x, min_y, max_x, max_y

def minimum_rotated_rectangle(points, return_angle=False):
"""
Compute the minimum rotated rectangle for an input point set.

This is the smallest enclosing rectangle (possibly rotated)
for the input point set. It is computed using OpenCV2, so
if that is not available, then this function will fail.

Parameters
----------
points : numpy.ndarray
A numpy array of shape (n_observations, 2) containing the point
locations to compute the rotated rectangle
return_angle : bool
whether to return the angle (in degrees) of the angle between
the horizontal axis of the rectanle and the first side (i.e. length).
Computed directly from cv2.minAreaRect.

Returns
-------
an numpy.ndarray of shape (4, 2) containing the coordinates
of the minimum rotated rectangle. If return_angle is True,
also return the angle (in degrees) of the rotated rectangle.

"""
points = np.asarray(points)
try:
from cv2 import minAreaRect, boxPoints
except (ImportError, ModuleNotFoundError):
raise ModuleNotFoundError("OpenCV2 is required to use this function. Please install it with `pip install opencv-contrib-python`")
((x, y), (w, h), angle) = rot_rect = minAreaRect(points.astype(np.float32))
out_points = boxPoints(rot_rect)
if return_angle:
return (out_points, angle)
return out_points

def mbr(points):
warnings.warn(
"This function will be deprecated in the next release of pointpats.",
FutureWarning,
stacklevel=2,
)
return minimum_bounding_rectangle(points)

mbr.__doc__ = minimum_bounding_rectangle.__doc__

[docs]
def hull(points):
"""
Find convex hull of a point array.

Parameters
----------
points: arraylike
(n,2), (x,y) coordinates of a series of event points.

Returns
-------
_     : array
(h,2), points defining the hull in counterclockwise order.
"""

points = np.asarray(points)
h = ConvexHull(points)
return points[h.vertices]

[docs]
def mean_center(points):
"""
Find mean center of a point array.

Parameters
----------
points: arraylike
(n,2), (x,y) coordinates of a series of event points.

Returns
-------
_     : array
(2,), (x,y) coordinates of the mean center.
"""

points = np.asarray(points)
return points.mean(axis=0)

[docs]
def weighted_mean_center(points, weights):
"""
Find weighted mean center of a marked point pattern.

Parameters
----------
points  : arraylike
(n,2), (x,y) coordinates of a series of event points.
weights : arraylike
a series of attribute values of length n.

Returns
-------
_      : array
(2,), (x,y) coordinates of the weighted mean center.
"""

points, weights = np.asarray(points), np.asarray(weights)
w = weights * 1.0 / weights.sum()
w.shape = (1, len(points))
return np.dot(w, points)[0]

[docs]
def manhattan_median(points):
"""
Find manhattan median of a point array.

Parameters
----------
points  : arraylike
(n,2), (x,y) coordinates of a series of event points.

Returns
-------
_      : array
(2,), (x,y) coordinates of the manhattan median.
"""

points = np.asarray(points)
if not len(points) % 2:
s = "Manhattan Median is not unique for even point patterns."
warnings.warn(s)
return np.median(points, axis=0)

[docs]
def std_distance(points):
"""
Calculate standard distance of a point array.

Parameters
----------
points  : arraylike
(n,2), (x,y) coordinates of a series of event points.

Returns
-------
_      : float
standard distance.
"""

points = np.asarray(points)
n, p = points.shape
m = points.mean(axis=0)
return np.sqrt(((points * points).sum(axis=0) / n - m * m).sum())

[docs]
def ellipse(points):
"""
Calculate parameters of standard deviational ellipse for a point pattern.

Parameters
----------
points : arraylike
(n,2), (x,y) coordinates of a series of event points.

Returns
-------
_      : float
semi-major axis.
_      : float
semi-minor axis.
theta  : float
clockwise rotation angle of the ellipse.

Notes
-----
Implements approach from:

https://www.icpsr.umich.edu/CrimeStat/files/CrimeStatChapter.4.pdf
"""

points = np.asarray(points)
n, k = points.shape
x = points[:, 0]
y = points[:, 1]
xd = x - x.mean()
yd = y - y.mean()
xss = (xd * xd).sum()
yss = (yd * yd).sum()
cv = (xd * yd).sum()
num = (xss - yss) + np.sqrt((xss - yss) ** 2 + 4 * (cv) ** 2)
den = 2 * cv
theta = np.arctan(num / den)
cos_theta = np.cos(theta)
sin_theta = np.sin(theta)
n_2 = n - 2
sd_x = (2 * (xd * cos_theta - yd * sin_theta) ** 2).sum() / n_2
sd_y = (2 * (xd * sin_theta - yd * cos_theta) ** 2).sum() / n_2
return np.sqrt(sd_x), np.sqrt(sd_y), theta

[docs]
def dtot(coord, points):
"""
Sum of Euclidean distances between event points and a selected point.

Parameters
----------
coord   : arraylike
(x,y) coordinates of a point.
points  : arraylike
(n,2), (x,y) coordinates of a series of event points.

Returns
-------
d       : float
sum of Euclidean distances.

"""
points = np.asarray(points)
xd = points[:, 0] - coord[0]
yd = points[:, 1] - coord[1]
d = np.sqrt(xd * xd + yd * yd).sum()
return d

[docs]
def euclidean_median(points):
"""
Calculate the Euclidean median for a point pattern.

Parameters
----------
points: arraylike
(n,2), (x,y) coordinates of a series of event points.

Returns
-------
_     : array
(2,), (x,y) coordinates of the Euclidean median.

"""
points = np.asarray(points)
start = mean_center(points)
res = minimize(dtot, start, args=(points,))
return res["x"]

def minimum_bounding_circle(points):
"""
Implements Skyum (1990)'s algorithm for the minimum bounding circle in R^2.

Store points clockwise.
Find p in S that maximizes angle(prec(p), p, succ(p) THEN radius(prec(
p), p, succ(p)). This is also called the lexicographic maximum, and is the last
entry of a list of (radius, angle) in lexicographical order.

* If angle(prec(p), p, succ(p)) <= 90 degrees, then finish.

* If not, remove p from set.

Parameters
----------
points  :   numpy.ndarray
a numpy array of shape (n_observations, 2) to compute
the minimum bounding circle

Returns
-------
(x,y),center for the minimum bounding circle.
"""
points = hull(points)
if not_clockwise(points):
points = points[::-1]
if not_clockwise(points):
raise Exception("Points are neither clockwise nor counterclockwise")
POINTS = copy.deepcopy(points)
removed = []
i = 0
if HAS_NUMBA:
circ = _skyum_numba(POINTS)[0]
else:
circ = _skyum_lists(POINTS)[0]
return (circ[1], circ[2]), circ[0]

[docs]
def skyum(points):
warnings.warn(
"This function will be deprecated in the next release of pointpats.",
FutureWarning,
stacklevel=2,
)
return minimum_bounding_circle(points)

skyum.__doc__ = (
"WARNING: This function is deprecated in favor of minimum_bounding_circle\n"
+ minimum_bounding_circle.__doc__
)

def _skyum_lists(points):
points = points.tolist()
removed = []
i = 0
while True:
angles = [_angle(_prec(p, points), p, _succ(p, points),) for p in points]
circles = [_circle(_prec(p, points), p, _succ(p, points),) for p in points]
radii = [c[0] for c in circles]
lexord = np.lexsort((radii, angles))  # confusing as hell defaults...
lexmax = lexord[-1]
candidate = (
_prec(points[lexmax], points),
points[lexmax],
_succ(points[lexmax], points),
)
if angles[lexmax] <= (np.pi / 2.0):
# print("Constrained by points: {}".format(candidate))
return _circle(*candidate), points, removed, candidate
else:
try:
removed.append((points.pop(lexmax), i))
except IndexError:
raise Exception("Construction of Minimum Bounding Circle failed!")
i += 1

try:
from numba import njit, boolean

HAS_NUMBA = True

@njit(fastmath=True)
def _skyum_numba(points):
i = 0
complete = False
while not complete:
complete, points, candidate, circle = _skyum_iteration(points)
if complete:
return circle, points, None, candidate

@njit(fastmath=True)
def _skyum_iteration(points):
points = points.reshape(-1, 2)
n = points.shape[0]
angles = np.empty((n,))
circles = np.empty((n, 3))
for i in range(n):
p = points[(i - 1) % n]
q = points[i % n]
r = points[(i + 1) % n]
angles[i] = _angle(p, q, r)
circles[i] = _circle(p, q, r)
radii = circles[:, 0]
# workaround for no lexsort in numba
angle_argmax = angles.argmax()
angle_max = angles[angle_argmax]
# the maximum radius for the largest angle
lexmax = (radii * (angles == angle_max)).argmax()

candidate = (lexmax - 1) % n, lexmax, (lexmax + 1) % n
if angles[lexmax] <= (np.pi / 2.0):
return True, points, lexmax, circles[lexmax]
else:
mask = np.ones((n,), dtype=boolean)
new_points = points[mask, :]
return False, new_points, lexmax, circles[lexmax]

except ModuleNotFoundError:

def njit(func, **kwargs):
return func

@njit
def _angle(p, q, r):
pq = p - q
rq = r - q
magnitudes = np.linalg.norm(pq) * np.linalg.norm(rq)
return np.abs(np.arccos(np.dot(pq, rq) / magnitudes))

def _prec(p, l):
"""
retrieve the predecessor of p in list l
"""
pos = l.index(p)
if pos - 1 < 0:
return l[-1]
else:
return l[pos - 1]

def _succ(p, l):
"""
retrieve the successor of p in list l
"""
pos = l.index(p)
if pos + 1 >= len(l):
return l[0]
else:
return l[pos + 1]

@njit
def _euclidean_distance(px, py, qx, qy):
return np.sqrt((px - qx) ** 2 + (py - qy) ** 2)

@njit
def _circle(p, q, r, dmetric=_euclidean_distance):
"""
Returns (radius, (center_x, center_y)) of the circumscribed circle by the
triangle pqr.

note, this does not assume that p!=q!=r
"""
px, py = p
qx, qy = q
rx, ry = r
angle = np.abs(_angle(p, q, r))
if np.abs(angle - np.pi) < 1e-5:  # angle is pi
radius = _euclidean_distance(px, py, rx, ry) / 2.0
center_x = (px + rx) / 2.0
center_y = (py + ry) / 2.0
elif np.abs(angle) < 1e-5:  # angle is zero
radius = _euclidean_distance(px, py, qx, qy) / 2.0
center_x = (px + qx) / 2.0
center_y = (py + qy) / 2.0
else:
D = 2 * (px * (qy - ry) + qx * (ry - py) + rx * (py - qy))
center_x = (
(px ** 2 + py ** 2) * (qy - ry)
+ (qx ** 2 + qy ** 2) * (ry - py)
+ (rx ** 2 + ry ** 2) * (py - qy)
) / float(D)
center_y = (
(px ** 2 + py ** 2) * (rx - qx)
+ (qx ** 2 + qy ** 2) * (px - rx)
+ (rx ** 2 + ry ** 2) * (qx - px)
) / float(D)
radius = _euclidean_distance(center_x, center_y, px, py)
return radius, center_x, center_y
```