Files
Apollo3D_SE/utils/math_ops.py
2026-03-10 09:35:27 -04:00

428 lines
16 KiB
Python

from math import acos, asin, atan2, cos, pi, sin, sqrt, isnan
from scipy.spatial.transform import Rotation
import numpy as np
import sys
try:
GLOBAL_DIR = sys._MEIPASS # temporary folder with libs & data files
except:
GLOBAL_DIR = "."
class MathOps():
'''
This class provides general mathematical operations that are not directly available through numpy
'''
@staticmethod
def deg_sph2cart(spherical_vec):
''' Converts spherical coordinates in degrees to cartesian coordinates '''
r = spherical_vec[0]
h = spherical_vec[1] * pi / 180
v = spherical_vec[2] * pi / 180
return np.array([r * cos(v) * cos(h), r * cos(v) * sin(h), r * sin(v)])
@staticmethod
def deg_sin(deg_angle):
''' Returns sin of degrees '''
return sin(deg_angle * pi / 180)
@staticmethod
def deg_cos(deg_angle):
''' Returns cos of degrees '''
return cos(deg_angle * pi / 180)
@staticmethod
def to_3d(vec_2d, value=0) -> np.ndarray:
''' Returns new 3d vector from 2d vector '''
return np.append(vec_2d, value)
@staticmethod
def to_2d_as_3d(vec_3d) -> np.ndarray:
''' Returns new 3d vector where the 3rd dimension is zero '''
vec_2d_as_3d = np.copy(vec_3d)
vec_2d_as_3d[2] = 0
return vec_2d_as_3d
@staticmethod
def normalize_vec(vec) -> np.ndarray:
''' Divides vector by its length '''
size = np.linalg.norm(vec)
if size == 0: return vec
return vec / size
def rel_to_global_3d(local_pos_3d: np.ndarray, global_pos_3d: np.ndarray,
global_orientation_quat: np.ndarray) -> np.ndarray:
''' Converts a local 3d position to a global 3d position given the global position and orientation (quaternion) '''
rotation = Rotation.from_quat(global_orientation_quat)
rotated_vec = rotation.apply(local_pos_3d)
global_pos = global_pos_3d + rotated_vec
return global_pos
@staticmethod
def get_active_directory(dir: str) -> str:
global GLOBAL_DIR
return GLOBAL_DIR + dir
@staticmethod
def acos(val):
''' arccosine function that limits input '''
return acos(np.clip(val, -1, 1))
@staticmethod
def asin(val):
''' arcsine function that limits input '''
return asin(np.clip(val, -1, 1))
@staticmethod
def normalize_deg(val):
''' normalize val in range [-180,180[ '''
return (val + 180.0) % 360 - 180
@staticmethod
def normalize_rad(val):
''' normalize val in range [-pi,pi[ '''
return (val + pi) % (2 * pi) - pi
@staticmethod
def deg_to_rad(val):
''' convert degrees to radians '''
return val * 0.01745329251994330
@staticmethod
def rad_to_deg(val):
''' convert radians to degrees '''
return val * 57.29577951308232
@staticmethod
def vector_angle(vector, is_rad=False):
''' angle (degrees or radians) of 2D vector '''
if is_rad:
return atan2(vector[1], vector[0])
else:
return atan2(vector[1], vector[0]) * 180 / pi
@staticmethod
def vectors_angle(vec1, vec2, is_rad=False):
''' get angle between vectors (degrees or radians) '''
ang_rad = acos(np.dot(MathOps.normalize_vec(vec1), MathOps.normalize_vec(vec2)))
return ang_rad if is_rad else ang_rad * 180 / pi
@staticmethod
def vector_from_angle(angle, is_rad=False):
''' unit vector with direction given by `angle` '''
if is_rad:
return np.array([cos(angle), sin(angle)], float)
else:
return np.array([MathOps.deg_cos(angle), MathOps.deg_sin(angle)], float)
@staticmethod
def target_abs_angle(pos2d, target, is_rad=False):
''' angle (degrees or radians) of vector (target-pos2d) '''
if is_rad:
return atan2(target[1] - pos2d[1], target[0] - pos2d[0])
else:
return atan2(target[1] - pos2d[1], target[0] - pos2d[0]) * 180 / pi
@staticmethod
def target_rel_angle(pos2d, ori, target, is_rad=False):
''' relative angle (degrees or radians) of target if we're located at 'pos2d' with orientation 'ori' (degrees or radians) '''
if is_rad:
return MathOps.normalize_rad(atan2(target[1] - pos2d[1], target[0] - pos2d[0]) - ori)
else:
return MathOps.normalize_deg(atan2(target[1] - pos2d[1], target[0] - pos2d[0]) * 180 / pi - ori)
@staticmethod
def rotate_2d_vec(vec, angle, is_rad=False):
''' rotate 2D vector anticlockwise around the origin by `angle` '''
cos_ang = cos(angle) if is_rad else cos(angle * pi / 180)
sin_ang = sin(angle) if is_rad else sin(angle * pi / 180)
return np.array([cos_ang * vec[0] - sin_ang * vec[1], sin_ang * vec[0] + cos_ang * vec[1]])
@staticmethod
def distance_point_to_line(p: np.ndarray, a: np.ndarray, b: np.ndarray):
'''
Distance between point p and 2d line 'ab' (and side where p is)
Parameters
----------
a : ndarray
2D point that defines line
b : ndarray
2D point that defines line
p : ndarray
2D point
Returns
-------
distance : float
distance between line and point
side : str
if we are at a, looking at b, p may be at our "left" or "right"
'''
line_len = np.linalg.norm(b - a)
if line_len == 0: # assumes vertical line
dist = sdist = np.linalg.norm(p - a)
else:
sdist = np.cross(b - a, p - a) / line_len
dist = abs(sdist)
return dist, "left" if sdist > 0 else "right"
@staticmethod
def distance_point_to_point_2d(p1: np.ndarray, p2: np.ndarray) -> float:
''' Distance in 2d from point p1 to point p2'''
return np.sqrt((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2)
@staticmethod
def is_point_between(p: np.ndarray, a: np.ndarray, b: np.ndarray) -> bool:
''' Verify if point 'p' is between 'a' and 'b' '''
p_2d = p[:2]
a_2d = a[:2]
b_2d = b[:2]
return np.all(np.minimum(a_2d, b_2d) <= p_2d) and np.all(p_2d <= np.maximum(a_2d, b_2d))
@staticmethod
def is_x_between(x1: np.ndarray, x2: np.ndarray, x3: np.ndarray) -> bool:
''' Verify if x-axis of point 'x1' is between 'x2' and 'x3' '''
return x1 >= min(x2, x3) and x1 <= max(x2, x3)
def distance_point_to_segment(p: np.ndarray, a: np.ndarray, b: np.ndarray):
''' Distance from point p to 2d line segment 'ab' '''
ap = p - a
ab = b - a
ad = MathOps.vector_projection(ap, ab)
# Is d in ab? We can find k in (ad = k * ab) without computing any norm
# we use the largest dimension of ab to avoid division by 0
k = ad[0] / ab[0] if abs(ab[0]) > abs(ab[1]) else ad[1] / ab[1]
if k <= 0:
return np.linalg.norm(ap)
elif k >= 1:
return np.linalg.norm(p - b)
else:
return np.linalg.norm(p - (ad + a)) # p-d
@staticmethod
def distance_point_to_ray(p: np.ndarray, ray_start: np.ndarray, ray_direction: np.ndarray):
''' Distance from point p to 2d ray '''
rp = p - ray_start
rd = MathOps.vector_projection(rp, ray_direction)
# Is d in ray? We can find k in (rd = k * ray_direction) without computing any norm
# we use the largest dimension of ray_direction to avoid division by 0
k = rd[0] / ray_direction[0] if abs(ray_direction[0]) > abs(ray_direction[1]) else rd[1] / ray_direction[1]
if k <= 0:
return np.linalg.norm(rp)
else:
return np.linalg.norm(p - (rd + ray_start)) # p-d
@staticmethod
def closest_point_on_ray_to_point(p: np.ndarray, ray_start: np.ndarray, ray_direction: np.ndarray):
''' Point on ray closest to point p '''
rp = p - ray_start
rd = MathOps.vector_projection(rp, ray_direction)
# Is d in ray? We can find k in (rd = k * ray_direction) without computing any norm
# we use the largest dimension of ray_direction to avoid division by 0
k = rd[0] / ray_direction[0] if abs(ray_direction[0]) > abs(ray_direction[1]) else rd[1] / ray_direction[1]
if k <= 0:
return ray_start
else:
return rd + ray_start
@staticmethod
def does_circle_intersect_segment(p: np.ndarray, r, a: np.ndarray, b: np.ndarray):
''' Returns true if circle (center p, radius r) intersect 2d line segment '''
ap = p - a
ab = b - a
ad = MathOps.vector_projection(ap, ab)
# Is d in ab? We can find k in (ad = k * ab) without computing any norm
# we use the largest dimension of ab to avoid division by 0
k = ad[0] / ab[0] if abs(ab[0]) > abs(ab[1]) else ad[1] / ab[1]
if k <= 0:
return np.dot(ap, ap) <= r * r
elif k >= 1:
return np.dot(p - b, p - b) <= r * r
dp = p - (ad + a)
return np.dot(dp, dp) <= r * r
@staticmethod
def vector_projection(a: np.ndarray, b: np.ndarray):
''' Vector projection of a onto b '''
b_dot = np.dot(b, b)
return b * np.dot(a, b) / b_dot if b_dot != 0 else b
@staticmethod
def do_noncollinear_segments_intersect(a, b, c, d):
'''
Check if 2d line segment 'ab' intersects with noncollinear 2d line segment 'cd'
Explanation: https://www.geeksforgeeks.org/check-if-two-given-line-segments-intersect/
'''
ccw = lambda a, b, c: (c[1] - a[1]) * (b[0] - a[0]) > (b[1] - a[1]) * (c[0] - a[0])
return ccw(a, c, d) != ccw(b, c, d) and ccw(a, b, c) != ccw(a, b, d)
@staticmethod
def intersection_segment_opp_goal(a: np.ndarray, b: np.ndarray):
''' Computes the intersection point of 2d segment 'ab' and the opponents' goal (front line) '''
vec_x = b[0] - a[0]
# Collinear intersections are not accepted
if vec_x == 0: return None
k = (15.01 - a[0]) / vec_x
# No collision
if k < 0 or k > 1: return None
intersection_pt = a + (b - a) * k
if -1.01 <= intersection_pt[1] <= 1.01:
return intersection_pt
else:
return None
@staticmethod
def intersection_circle_opp_goal(p: np.ndarray, r):
'''
Computes the intersection segment of circle (center p, radius r) and the opponents' goal (front line)
Only the y coordinates are returned since the x coordinates are always equal to 15
'''
x_dev = abs(15 - p[0])
if x_dev > r:
return None # no intersection with x=15
y_dev = sqrt(r * r - x_dev * x_dev)
p1 = max(p[1] - y_dev, -1.01)
p2 = min(p[1] + y_dev, 1.01)
if p1 == p2:
return p1 # return the y coordinate of a single intersection point
elif p2 < p1:
return None # no intersection
else:
return p1, p2 # return the y coordinates of the intersection segment
@staticmethod
def distance_point_to_opp_goal(p: np.ndarray):
''' Distance between point 'p' and the opponents' goal (front line) '''
if p[1] < -1.01:
return np.linalg.norm(p - (15, -1.01))
elif p[1] > 1.01:
return np.linalg.norm(p - (15, 1.01))
else:
return abs(15 - p[0])
@staticmethod
def circle_line_segment_intersection(circle_center, circle_radius, pt1, pt2, full_line=True, tangent_tol=1e-9):
""" Find the points at which a circle intersects a line-segment. This can happen at 0, 1, or 2 points.
:param circle_center: The (x, y) location of the circle center
:param circle_radius: The radius of the circle
:param pt1: The (x, y) location of the first point of the segment
:param pt2: The (x, y) location of the second point of the segment
:param full_line: True to find intersections along full line - not just in the segment. False will just return intersections within the segment.
:param tangent_tol: Numerical tolerance at which we decide the intersections are close enough to consider it a tangent
:return Sequence[Tuple[float, float]]: A list of length 0, 1, or 2, where each element is a point at which the circle intercepts a line segment.
Note: We follow: http://mathworld.wolfram.com/Circle-LineIntersection.html
"""
(p1x, p1y), (p2x, p2y), (cx, cy) = pt1, pt2, circle_center
(x1, y1), (x2, y2) = (p1x - cx, p1y - cy), (p2x - cx, p2y - cy)
dx, dy = (x2 - x1), (y2 - y1)
dr = (dx ** 2 + dy ** 2) ** .5
big_d = x1 * y2 - x2 * y1
discriminant = circle_radius ** 2 * dr ** 2 - big_d ** 2
if discriminant < 0: # No intersection between circle and line
return []
else: # There may be 0, 1, or 2 intersections with the segment
intersections = [
(cx + (big_d * dy + sign * (-1 if dy < 0 else 1) * dx * discriminant ** .5) / dr ** 2,
cy + (-big_d * dx + sign * abs(dy) * discriminant ** .5) / dr ** 2)
for sign in ((1, -1) if dy < 0 else (-1, 1))] # This makes sure the order along the segment is correct
if not full_line: # If only considering the segment, filter out intersections that do not fall within the segment
fraction_along_segment = [
(xi - p1x) / dx if abs(dx) > abs(dy) else (yi - p1y) / dy for xi, yi in intersections]
intersections = [pt for pt, frac in zip(
intersections, fraction_along_segment) if 0 <= frac <= 1]
# If line is tangent to circle, return just one point (as both intersections have same location)
if len(intersections) == 2 and abs(discriminant) <= tangent_tol:
return [intersections[0]]
else:
return intersections
# adapted from https://stackoverflow.com/questions/3252194/numpy-and-line-intersections
@staticmethod
def get_line_intersection(a1, a2, b1, b2):
"""
Returns the point of intersection of the lines passing through a2,a1 and b2,b1.
a1: [x, y] a point on the first line
a2: [x, y] another point on the first line
b1: [x, y] a point on the second line
b2: [x, y] another point on the second line
"""
s = np.vstack([a1, a2, b1, b2]) # s for stacked
h = np.hstack((s, np.ones((4, 1)))) # h for homogeneous
l1 = np.cross(h[0], h[1]) # get first line
l2 = np.cross(h[2], h[3]) # get second line
x, y, z = np.cross(l1, l2) # point of intersection
if z == 0: # lines are parallel
return np.array([float('inf'), float('inf')])
return np.array([x / z, y / z], float)
@staticmethod
def get_score_based_on_tanh(input, vertical_scaling=0.5, horizontal_scaling=1, vertical_shift=0.5,
horizontal_shift=0) -> float:
'''
Based on hiperbolic tangent function, it's calculated a score between 0 and 1 (or others, depending on the parameters)
Function: a * tanh(x * c + d) + b
:param input: x value of the function
:param vertical_scaling: strech (a > 1) or compress (0 < a < 1) the function
:param horizontal_scaling: strech (c > 1) or compress (0 < c < 1) the function
:param vertical_shift: shift up (b > 0) or down (b < 0) the function
:param horizontal_shift: shift right (d < 0) or left (d > 0) the function
'''
return vertical_scaling * np.tanh(input * horizontal_scaling + horizontal_shift) + vertical_shift
@staticmethod
def get_angle_to_origin_radians(point: np.ndarray):
x, y = point
if x == 0:
return pi / 2 if y > 0 else -pi / 2
result = atan2(y, x)
result = result if not isnan(result) else pi / 2
return MathOps.normalize_rad(result)