Source code for pylinac.core.geometry

"""Module for classes that represent common geometric objects or patterns."""
from itertools import zip_longest
import math
from typing import Union, Optional, List, Iterable, Tuple

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Circle as mpl_Circle
from matplotlib.patches import Rectangle as mpl_Rectangle

from .utilities import is_iterable
from .typing import NumberLike


[docs]def tan(degrees: NumberLike) -> float: """Calculate the tangent of the given degrees.""" return math.tan(math.radians(degrees))
[docs]def cos(degrees: NumberLike) -> float: """Calculate the cosine of the given degrees.""" return math.cos(math.radians(degrees))
[docs]def sin(degrees: NumberLike) -> float: """Calculate the sine of the given degrees.""" return math.sin(math.radians(degrees))
[docs]class Point: """A geometric point with x, y, and z coordinates/attributes.""" z: Union[int, float] y: Union[int, float] x: Union[int, float] _attr_list: List[str] = ['x', 'y', 'z', 'idx', 'value'] _coord_list: List[str] = ['x', 'y', 'z'] def __init__(self, x: Union[NumberLike, tuple, 'Point'] = 0, y: NumberLike = 0, z: NumberLike = 0, idx: Optional[int]=None, value: Optional[NumberLike]=None, as_int: bool=False): """ Parameters ---------- x : number-like, Point, iterable x-coordinate or iterable type containing all coordinates. If iterable, values are assumed to be in order: (x,y,z). y : number-like, optional y-coordinate idx : int, optional Index of point. Useful for sequential coordinates; e.g. a point on a circle profile is sometimes easier to describe in terms of its index rather than x,y coords. value : number-like, optional value at point location (e.g. pixel value of an image) as_int : boolean If True, coordinates are converted to integers. """ if isinstance(x, Point): for attr in self._attr_list: item = getattr(x, attr, None) setattr(self, attr, item) elif is_iterable(x): for attr, item in zip_longest(self._attr_list, x, fillvalue=0): setattr(self, attr, item) else: self.x = x self.y = y self.z = z self.idx = idx self.value = value if as_int: self.x = int(round(self.x)) self.y = int(round(self.y)) self.z = int(round(self.z))
[docs] def distance_to(self, thing: Union['Point', 'Circle']) -> float: """Calculate the distance to the given point. Parameters ---------- thing : Circle, Point, 2 element iterable The other thing to calculate distance to. """ if isinstance(thing, Circle): return abs(np.sqrt((self.x - thing.center.x)**2 + (self.y - thing.center.y)**2) - thing.radius) p = Point(thing) return math.sqrt((self.x - p.x)**2 + (self.y - p.y)**2 + (self.z - p.z)**2)
[docs] def as_array(self, only_coords: bool=True) -> np.array: """Return the point as a numpy array.""" if only_coords: return np.array([getattr(self, item) for item in self._coord_list]) else: return np.array([getattr(self, item) for item in self._attr_list if (getattr(self, item) is not None)])
def __repr__(self): return f"Point(x={self.x:3.2f}, y={self.y:3.2f}, z={self.z:3.2f})" def __eq__(self, other): # if all attrs equal, points considered equal return all(getattr(self, attr) == getattr(other, attr) for attr in self._attr_list) def __sub__(self, other): p = Point() for attr in self._attr_list: try: diff = getattr(self, attr) - getattr(other, attr) except TypeError: diff = None setattr(p, attr, diff) return p def __mul__(self, other): for attr in self._attr_list: try: self.__dict__[attr] *= other except TypeError: pass def __truediv__(self, other): for attr in self._attr_list: val = getattr(self, attr) try: setattr(self, attr, val/other) except TypeError: pass return self
[docs]class Circle: """A geometric circle with center Point, radius, and diameter.""" center: Point radius: float def __init__(self, center_point: Union[Point, Iterable]=(0, 0), radius: float = 0): """ Parameters ---------- center_point : Point, optional Center point of the wobble circle. radius : float, optional Radius of the wobble circle. """ if center_point is None: center_point = Point() elif isinstance(center_point, Point) or is_iterable(center_point): center_point = Point(center_point) else: raise TypeError("Circle center must be of type Point or iterable") self.center = center_point self.radius = radius @property def diameter(self) -> float: """Get the diameter of the circle.""" return self.radius*2
[docs] def plot2axes(self, axes, edgecolor: str='black', fill: bool=False) -> None: """Plot the Circle on the axes. Parameters ---------- axes : matplotlib.axes.Axes An MPL axes to plot to. edgecolor : str The color of the circle. fill : bool Whether to fill the circle with color or leave hollow. """ axes.add_patch(mpl_Circle((self.center.x, self.center.y), edgecolor=edgecolor, radius=self.radius, fill=fill))
[docs]class Vector: """A vector with x, y, and z coordinates.""" x: NumberLike y: NumberLike z: NumberLike def __init__(self, x: NumberLike=0, y: NumberLike=0, z: NumberLike=0): self.x = x self.y = y self.z = z def __repr__(self): return f"Vector(x={self.x:.2f}, y={self.y:.2f}, z={self.z:.2f})"
[docs] def as_scalar(self) -> float: """Return the scalar equivalent of the vector.""" return math.sqrt(self.x**2 + self.y**2 + self.z**2)
[docs] def distance_to(self, thing: Union[Circle, Point]) -> float: """Calculate the distance to the given point. Parameters ---------- thing : Circle, Point, 2 element iterable The other point to calculate distance to. """ if isinstance(thing, Circle): return abs(np.sqrt((self.x - thing.center.x)**2 + (self.y - thing.center.y)**2) - thing.radius) else: p = Point(thing) return math.sqrt((self.x - p.x)**2 + (self.y - p.y)**2 + (self.z - p.z)**2)
def __sub__(self, other): new_x = self.x - other.x new_y = self.y - other.y new_z = self.z - other.z return Vector(x=new_x, y=new_y, z=new_z) def __add__(self, other): new_x = self.x + other.x new_y = self.y + other.y new_z = self.z + other.z return Vector(x=new_x, y=new_y, z=new_z)
[docs]def vector_is_close(vector1: Vector, vector2: Vector, delta: float=0.1) -> bool: """Determine if two vectors are with delta of each other; this is a simple coordinate comparison check.""" for attr in ('x', 'y', 'z'): if np.isnan(getattr(vector1, attr)) and np.isnan(getattr(vector2, attr)): continue if not getattr(vector2, attr) + delta >= getattr(vector1, attr) >= getattr(vector2, attr) - delta: return False return True
[docs]class Line: """A line that is represented by two points or by m*x+b. Notes ----- Calculations of slope, etc are from here: http://en.wikipedia.org/wiki/Linear_equation and here: http://www.mathsisfun.com/algebra/line-equation-2points.html """ point1: Point point2: Point def __init__(self, point1: Union[Point, Tuple[float, float]], point2: Union[Point, Tuple[float, float]]): """ Parameters ---------- point1 : Point One point of the line point2 : Point Second point along the line. """ self.point1 = Point(point1) self.point2 = Point(point2) def __repr__(self): return f'Line: p1:(x={self.point1.x:.1f}, y={self.point1.y:.1f}, z={self.point1.z:.1f}), ' \ f'p2:(x={self.point2.x:.1f}, y={self.point2.y:.1f}, z={self.point2.z:.1f})' @property def m(self) -> float: """Return the slope of the line. m = (y1 - y2)/(x1 - x2) From: http://www.purplemath.com/modules/slope.htm """ return (self.point1.y - self.point2.y) / (self.point1.x - self.point2.x) @property def b(self) -> float: """Return the y-intercept of the line. b = y - m*x """ return self.point1.y - (self.m * self.point1.x)
[docs] def y(self, x) -> float: """Return y-value along line at position x.""" return self.m * x + self.b
[docs] def x(self, y) -> float: """Return x-value along line at position y.""" return (y - self.b)/self.m
@property def center(self) -> Point: """Return the center of the line as a Point.""" mid_x = np.abs((self.point2.x - self.point1.x)/2 + self.point1.x) mid_y = (self.point2.y - self.point1.y) / 2 + self.point1.y return Point(mid_x, mid_y) @property def length(self) -> float: """Return length of the line, if finite.""" return self.point1.distance_to(self.point2)
[docs] def distance_to(self, point) -> float: """Calculate the minimum distance from the line to a point. Equations are from here: http://mathworld.wolfram.com/Point-LineDistance2-Dimensional.html #14 Parameters ---------- point : Point, iterable The point to calculate distance to. """ point = Point(point).as_array() lp1 = self.point1.as_array() lp2 = self.point2.as_array() numerator = np.sqrt(np.sum(np.power(np.cross((lp2 - lp1), (lp1 - point)), 2))) denominator = np.sqrt(np.sum(np.power(lp2 - lp1, 2))) return numerator/denominator
[docs] def plot2axes(self, axes: plt.Axes, width: NumberLike=1, color: str='w') -> None: """Plot the line to an axes. Parameters ---------- axes : matplotlib.axes.Axes An MPL axes to plot to. color : str The color of the line. """ axes.plot((self.point1.x, self.point2.x), (self.point1.y, self.point2.y), linewidth=width, color=color)
[docs]class Rectangle: """A rectangle with width, height, center Point, top-left corner Point, and bottom-left corner Point.""" width: Union[int, float] height: Union[int, float] _as_int: bool center: Point def __init__(self, width: float, height: float, center: Union[Point, Tuple], as_int: bool=False): """ Parameters ---------- width : number Width of the rectangle. height : number Height of the rectangle. center : Point, iterable, optional Center point of rectangle. as_int : bool If False (default), inputs are left as-is. If True, all inputs are converted to integers. """ if as_int: self.width = int(np.round(width)) self.height = int(np.round(height)) else: self.width = width self.height = height self._as_int = as_int self.center = Point(center, as_int=as_int) @property def br_corner(self) -> Point: """The location of the bottom right corner.""" return Point(self.center.x + self.width / 2, self.center.y - self.height / 2, as_int=self._as_int) @property def bl_corner(self) -> Point: """The location of the bottom left corner.""" return Point(self.center.x - self.width / 2, self.center.y - self.height / 2, as_int=self._as_int) @property def tl_corner(self) -> Point: """The location of the top left corner.""" return Point(self.center.x - self.width / 2, self.center.y + self.height / 2, as_int=self._as_int) @property def tr_corner(self) -> Point: """The location of the top right corner.""" return Point(self.center.x + self.width / 2, self.center.y + self.height / 2, as_int=self._as_int)
[docs] def plot2axes(self, axes: plt.Axes, edgecolor: str='black', angle: float=0.0, fill: bool=False, alpha: float=1, facecolor: str='g', label=None): """Plot the Rectangle to the axes. Parameters ---------- axes : matplotlib.axes.Axes An MPL axes to plot to. edgecolor : str The color of the circle. angle : float Angle of the rectangle. fill : bool Whether to fill the rectangle with color or leave hollow. """ axes.add_patch(mpl_Rectangle((self.bl_corner.x, self.bl_corner.y), width=self.width, height=self.height, angle=angle, edgecolor=edgecolor, alpha=alpha, facecolor=facecolor, fill=fill, label=label))