From fc1a0f5a5a14f7f6258bc3e90fa6889bc2e7b77e Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 16 May 2021 14:25:22 -0700 Subject: [PATCH] break apart utils into submodules, and add utils.pack2d --- masque/utils/__init__.py | 15 +++++++ masque/utils/array.py | 11 +++++ masque/utils/autoslots.py | 27 ++++++++++++ masque/utils/bitwise.py | 34 +++++++++++++++ masque/utils/pack2d.py | 88 +++++++++++++++++++++++++++++++++++++++ masque/utils/transform.py | 40 ++++++++++++++++++ masque/utils/types.py | 8 ++++ masque/utils/vertices.py | 54 ++++++++++++++++++++++++ 8 files changed, 277 insertions(+) create mode 100644 masque/utils/__init__.py create mode 100644 masque/utils/array.py create mode 100644 masque/utils/autoslots.py create mode 100644 masque/utils/bitwise.py create mode 100644 masque/utils/pack2d.py create mode 100644 masque/utils/transform.py create mode 100644 masque/utils/types.py create mode 100644 masque/utils/vertices.py diff --git a/masque/utils/__init__.py b/masque/utils/__init__.py new file mode 100644 index 0000000..1315e0f --- /dev/null +++ b/masque/utils/__init__.py @@ -0,0 +1,15 @@ +""" +Various helper functions, type definitions, etc. +""" +from .types import layer_t, annotations_t + +from .array import is_scalar +from .autoslots import AutoSlots + +from .bitwise import get_bit, set_bit +from .vertices import remove_duplicate_vertices, remove_colinear_vertices +from .transform import rotation_matrix_2d, normalize_mirror + +from . import pack2d + + diff --git a/masque/utils/array.py b/masque/utils/array.py new file mode 100644 index 0000000..50fce86 --- /dev/null +++ b/masque/utils/array.py @@ -0,0 +1,11 @@ +from typing import Any + + +def is_scalar(var: Any) -> bool: + """ + Alias for 'not hasattr(var, "__len__")' + + Args: + var: Checks if `var` has a length. + """ + return not hasattr(var, "__len__") diff --git a/masque/utils/autoslots.py b/masque/utils/autoslots.py new file mode 100644 index 0000000..4b1d001 --- /dev/null +++ b/masque/utils/autoslots.py @@ -0,0 +1,27 @@ +from abc import ABCMeta + + +class AutoSlots(ABCMeta): + """ + Metaclass for automatically generating __slots__ based on superclass type annotations. + + Superclasses must set `__slots__ = ()` to make this work properly. + + This is a workaround for the fact that non-empty `__slots__` can't be used + with multiple inheritance. Since we only use multiple inheritance with abstract + classes, they can have empty `__slots__` and their attribute type annotations + can be used to generate a full `__slots__` for the concrete class. + """ + def __new__(cls, name, bases, dctn): + parents = set() + for base in bases: + parents |= set(base.mro()) + + slots = tuple(dctn.get('__slots__', tuple())) + for parent in parents: + if not hasattr(parent, '__annotations__'): + continue + slots += tuple(getattr(parent, '__annotations__').keys()) + + dctn['__slots__'] = slots + return super().__new__(cls, name, bases, dctn) diff --git a/masque/utils/bitwise.py b/masque/utils/bitwise.py new file mode 100644 index 0000000..d33b14e --- /dev/null +++ b/masque/utils/bitwise.py @@ -0,0 +1,34 @@ +from typing import Any + + +def get_bit(bit_string: Any, bit_id: int) -> bool: + """ + Interprets bit number `bit_id` from the right (lsb) of `bit_string` as a boolean + + Args: + bit_string: Bit string to test + bit_id: Bit number, 0-indexed from the right (lsb) + + Returns: + Boolean value of the requested bit + """ + return bit_string & (1 << bit_id) != 0 + + +def set_bit(bit_string: Any, bit_id: int, value: bool) -> Any: + """ + Returns `bit_string`, with bit number `bit_id` set to boolean `value`. + + Args: + bit_string: Bit string to alter + bit_id: Bit number, 0-indexed from right (lsb) + value: Boolean value to set bit to + + Returns: + Altered `bit_string` + """ + mask = (1 << bit_id) + bit_string &= ~mask + if value: + bit_string |= mask + return bit_string diff --git a/masque/utils/pack2d.py b/masque/utils/pack2d.py new file mode 100644 index 0000000..fef30ec --- /dev/null +++ b/masque/utils/pack2d.py @@ -0,0 +1,88 @@ +""" +2D bin-packing +""" +from typing import Tuple + +import numpy +from numpy.typing import NDArray, ArrayLike + +from ..error import MasqueError + + +def maxrects_bssf( + rects: ArrayLike, + containers: ArrayLike, + presort: bool = True, + allow_rejects: bool = True, + ) -> Tuple[NDArray[numpy.float64], NDArray[numpy.float64]]: + """ + sizes should be Nx2 + regions should be Mx4 (xmin, ymin, xmax, ymax) + """ + regions = numpy.array(containers, copy=False, dtype=float) + rect_sizes = numpy.array(rects, copy=False, dtype=float) + rect_locs = numpy.zeros_like(rect_sizes) + rejected_rects = [] + + if presort: + rotated_sizes = numpy.sort(rect_sizes, axis=0) # shortest side first + rect_order = numpy.lexsort(rotated_sizes.T)[::-1] # Descending shortest side + rect_sizes = rect_sizes[rect_order] + + for rect_ind, rect_size in enumerate(rect_sizes): + ''' Remove degenerate regions ''' + # First remove duplicate regions (but keep one; code below would drop both) + regions = numpy.unique(regions, axis=0) + + # Now remove regions enclosed in another + min_more = (regions[None, :, :2] >= regions[:, None, :2]).all(axis=2) # first axis > second axis + max_less = (regions[None, :, 2:] <= regions[:, None, 2:]).all(axis=2) # first axis < second axis + max_less &= ~numpy.eye(regions.shape[0], dtype=bool) # exclude self + degenerate = (min_more & max_less).any(axis=0) + regions = regions[~degenerate] + + ''' Place the rect ''' + # Best short-side fit (bssf) to pick a region + bssf_scores = ((regions[:, 2:] - regions[:, :2]) - rect_size).min(axis=1).astype(float) + bssf_scores[bssf_scores < 0] = numpy.inf # doesn't fit! + rr = bssf_scores.argmin() + if numpy.isinf(bssf_scores[rr]): + if allow_rejects: + rejected_rects.append(rect_ind) + continue + else: + raise MasqueError(f'Failed to find a suitable location for rectangle {rect_ind}') + + # Read out location + loc = regions[rr, :2] + rect_locs[rect_ind] = loc + + ''' Shatter regions ''' + # Which regions does this rectangle intersect? + min_over = regions[:, :2] >= loc + rect_size + max_undr = regions[:, 2:] <= loc + intersects = ~(min_over | max_undr).any(axis=1) + + # Which sides is there excess on? + region_past_botleft = intersects[:, None] & (regions[:, :2] < loc) + region_past_topright = intersects[:, None] & (regions[:, 2:] > loc + rect_size) + + # Create new regions + r_lft = regions[region_past_botleft[:, 0]].copy() + r_bot = regions[region_past_botleft[:, 1]].copy() + r_rgt = regions[region_past_topright[:, 0]].copy() + r_top = regions[region_past_topright[:, 1]].copy() + + r_lft[:, 2] = loc[0] + r_bot[:, 3] = loc[1] + r_rgt[:, 0] = loc[0] + rect_size[0] + r_top[:, 1] = loc[1] + rect_size[1] + + regions = numpy.vstack((regions[~intersects], r_lft, r_bot, r_rgt, r_top)) + + if rejected_rects: + rejected_rects_arr = numpy.vstack(rejected_rects) + else: + rejected_rects_arr = numpy.empty((0, 2)) + + return rect_locs, rejected_rects_arr diff --git a/masque/utils/transform.py b/masque/utils/transform.py new file mode 100644 index 0000000..92f52c5 --- /dev/null +++ b/masque/utils/transform.py @@ -0,0 +1,40 @@ +""" +Geometric transforms +""" +from typing import Sequence, Tuple + +import numpy +from numpy.typing import NDArray + + +def rotation_matrix_2d(theta: float) -> NDArray[numpy.float64]: + """ + 2D rotation matrix for rotating counterclockwise around the origin. + + Args: + theta: Angle to rotate, in radians + + Returns: + rotation matrix + """ + return numpy.array([[numpy.cos(theta), -numpy.sin(theta)], + [numpy.sin(theta), +numpy.cos(theta)]]) + + +def normalize_mirror(mirrored: Sequence[bool]) -> Tuple[bool, float]: + """ + Converts 0-2 mirror operations `(mirror_across_x_axis, mirror_across_y_axis)` + into 0-1 mirror operations and a rotation + + Args: + mirrored: `(mirror_across_x_axis, mirror_across_y_axis)` + + Returns: + `mirror_across_x_axis` (bool) and + `angle_to_rotate` in radians + """ + + mirrored_x, mirrored_y = mirrored + mirror_x = (mirrored_x != mirrored_y) # XOR + angle = numpy.pi if mirrored_y else 0 + return mirror_x, angle diff --git a/masque/utils/types.py b/masque/utils/types.py new file mode 100644 index 0000000..b0d7acd --- /dev/null +++ b/masque/utils/types.py @@ -0,0 +1,8 @@ +""" +Type definitions +""" +from typing import Union, Tuple, Sequence, Dict, List + + +layer_t = Union[int, Tuple[int, int], str] +annotations_t = Dict[str, List[Union[int, float, str]]] diff --git a/masque/utils/vertices.py b/masque/utils/vertices.py new file mode 100644 index 0000000..5e76ca2 --- /dev/null +++ b/masque/utils/vertices.py @@ -0,0 +1,54 @@ +""" +Vertex list operations +""" +import numpy +from numpy.typing import NDArray, ArrayLike + + +def remove_duplicate_vertices(vertices: ArrayLike, closed_path: bool = True) -> NDArray[numpy.float64]: + """ + Given a list of vertices, remove any consecutive duplicates. + + Args: + vertices: `[[x0, y0], [x1, y1], ...]` + closed_path: If True, `vertices` is interpreted as an implicity-closed path + (i.e. the last vertex will be removed if it is the same as the first) + + Returns: + `vertices` with no consecutive duplicates. + """ + vertices = numpy.array(vertices) + duplicates = (vertices == numpy.roll(vertices, 1, axis=0)).all(axis=1) + if not closed_path: + duplicates[0] = False + return vertices[~duplicates] + + +def remove_colinear_vertices(vertices: ArrayLike, closed_path: bool = True) -> NDArray[numpy.float64]: + """ + Given a list of vertices, remove any superflous vertices (i.e. + those which lie along the line formed by their neighbors) + + Args: + vertices: Nx2 ndarray of vertices + closed_path: If `True`, the vertices are assumed to represent an implicitly + closed path. If `False`, the path is assumed to be open. Default `True`. + + Returns: + `vertices` with colinear (superflous) vertices removed. + """ + vertices = remove_duplicate_vertices(vertices) + + # Check for dx0/dy0 == dx1/dy1 + + dv = numpy.roll(vertices, -1, axis=0) - vertices # [y1-y0, y2-y1, ...] + dxdy = dv * numpy.roll(dv, 1, axis=0)[:, ::-1] # [[dx0*(dy_-1), (dx_-1)*dy0], dx1*dy0, dy1*dx0]] + + dxdy_diff = numpy.abs(numpy.diff(dxdy, axis=1))[:, 0] + err_mult = 2 * numpy.abs(dxdy).sum(axis=1) + 1e-40 + + slopes_equal = (dxdy_diff / err_mult) < 1e-15 + if not closed_path: + slopes_equal[[0, -1]] = False + + return vertices[~slopes_equal]