[RectCollection] add a RectCollection shape
This commit is contained in:
parent
7130d26112
commit
ec78031565
5 changed files with 343 additions and 1 deletions
|
|
@ -42,6 +42,7 @@ from .error import (
|
||||||
from .shapes import (
|
from .shapes import (
|
||||||
Shape as Shape,
|
Shape as Shape,
|
||||||
Polygon as Polygon,
|
Polygon as Polygon,
|
||||||
|
RectCollection as RectCollection,
|
||||||
Path as Path,
|
Path as Path,
|
||||||
Circle as Circle,
|
Circle as Circle,
|
||||||
Arc as Arc,
|
Arc as Arc,
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ from klamath import records
|
||||||
|
|
||||||
from .utils import is_gzipped, tmpfile
|
from .utils import is_gzipped, tmpfile
|
||||||
from .. import Pattern, Ref, PatternError, LibraryError, Label, Shape
|
from .. import Pattern, Ref, PatternError, LibraryError, Label, Shape
|
||||||
from ..shapes import Polygon, Path
|
from ..shapes import Polygon, Path, RectCollection
|
||||||
from ..repetition import Grid
|
from ..repetition import Grid
|
||||||
from ..utils import layer_t, annotations_t
|
from ..utils import layer_t, annotations_t
|
||||||
from ..library import LazyLibrary, Library, ILibrary, ILibraryView
|
from ..library import LazyLibrary, Library, ILibrary, ILibraryView
|
||||||
|
|
@ -466,6 +466,20 @@ def _shapes_to_elements(
|
||||||
properties=properties,
|
properties=properties,
|
||||||
)
|
)
|
||||||
elements.append(path)
|
elements.append(path)
|
||||||
|
elif isinstance(shape, RectCollection):
|
||||||
|
for rect in shape.rects:
|
||||||
|
xy_closed = numpy.empty((5, 2), dtype=numpy.int32)
|
||||||
|
xy_closed[0] = rint_cast((rect[0], rect[1]))
|
||||||
|
xy_closed[1] = rint_cast((rect[0], rect[3]))
|
||||||
|
xy_closed[2] = rint_cast((rect[2], rect[3]))
|
||||||
|
xy_closed[3] = rint_cast((rect[2], rect[1]))
|
||||||
|
xy_closed[4] = xy_closed[0]
|
||||||
|
boundary = klamath.elements.Boundary(
|
||||||
|
layer=(layer, data_type),
|
||||||
|
xy=xy_closed,
|
||||||
|
properties=properties,
|
||||||
|
)
|
||||||
|
elements.append(boundary)
|
||||||
elif isinstance(shape, Polygon):
|
elif isinstance(shape, Polygon):
|
||||||
polygon = shape
|
polygon = shape
|
||||||
xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32)
|
xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from .shape import (
|
||||||
|
|
||||||
from .polygon import Polygon as Polygon
|
from .polygon import Polygon as Polygon
|
||||||
from .poly_collection import PolyCollection as PolyCollection
|
from .poly_collection import PolyCollection as PolyCollection
|
||||||
|
from .rect_collection import RectCollection as RectCollection
|
||||||
from .circle import Circle as Circle
|
from .circle import Circle as Circle
|
||||||
from .ellipse import Ellipse as Ellipse
|
from .ellipse import Ellipse as Ellipse
|
||||||
from .arc import Arc as Arc
|
from .arc import Arc as Arc
|
||||||
|
|
|
||||||
256
masque/shapes/rect_collection.py
Normal file
256
masque/shapes/rect_collection.py
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
from typing import Any, cast, Self
|
||||||
|
from collections.abc import Iterator
|
||||||
|
import copy
|
||||||
|
import functools
|
||||||
|
|
||||||
|
import numpy
|
||||||
|
from numpy import pi
|
||||||
|
from numpy.typing import NDArray, ArrayLike
|
||||||
|
|
||||||
|
from . import Shape, normalized_shape_tuple
|
||||||
|
from .polygon import Polygon
|
||||||
|
from ..error import PatternError
|
||||||
|
from ..repetition import Repetition
|
||||||
|
from ..utils import annotations_lt, annotations_eq, rep2key, annotations_t
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_rects(rects: ArrayLike) -> NDArray[numpy.float64]:
|
||||||
|
arr = numpy.asarray(rects, dtype=float)
|
||||||
|
if arr.ndim != 2 or arr.shape[1] != 4:
|
||||||
|
raise PatternError('Rectangles must be an Nx4 array of [xmin, ymin, xmax, ymax]')
|
||||||
|
if numpy.any(arr[:, 0] > arr[:, 2]) or numpy.any(arr[:, 1] > arr[:, 3]):
|
||||||
|
raise PatternError('Rectangles must satisfy xmin <= xmax and ymin <= ymax')
|
||||||
|
if arr.shape[0] <= 1:
|
||||||
|
return arr
|
||||||
|
order = numpy.lexsort((arr[:, 3], arr[:, 2], arr[:, 1], arr[:, 0]))
|
||||||
|
return arr[order]
|
||||||
|
|
||||||
|
|
||||||
|
def _renormalize_rects_in_place(rects: NDArray[numpy.float64]) -> None:
|
||||||
|
x0 = numpy.minimum(rects[:, 0], rects[:, 2])
|
||||||
|
x1 = numpy.maximum(rects[:, 0], rects[:, 2])
|
||||||
|
y0 = numpy.minimum(rects[:, 1], rects[:, 3])
|
||||||
|
y1 = numpy.maximum(rects[:, 1], rects[:, 3])
|
||||||
|
rects[:, 0] = x0
|
||||||
|
rects[:, 1] = y0
|
||||||
|
rects[:, 2] = x1
|
||||||
|
rects[:, 3] = y1
|
||||||
|
|
||||||
|
|
||||||
|
@functools.total_ordering
|
||||||
|
class RectCollection(Shape):
|
||||||
|
"""
|
||||||
|
A collection of axis-aligned rectangles, stored as an Nx4 array of
|
||||||
|
`[xmin, ymin, xmax, ymax]` rows.
|
||||||
|
"""
|
||||||
|
__slots__ = (
|
||||||
|
'_rects',
|
||||||
|
'_repetition', '_annotations',
|
||||||
|
)
|
||||||
|
|
||||||
|
_rects: NDArray[numpy.float64]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rects(self) -> NDArray[numpy.float64]:
|
||||||
|
return self._rects
|
||||||
|
|
||||||
|
@rects.setter
|
||||||
|
def rects(self, val: ArrayLike) -> None:
|
||||||
|
self._rects = _normalize_rects(val)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def offset(self) -> NDArray[numpy.float64]:
|
||||||
|
return numpy.zeros(2)
|
||||||
|
|
||||||
|
@offset.setter
|
||||||
|
def offset(self, val: ArrayLike) -> None:
|
||||||
|
if numpy.any(val):
|
||||||
|
raise PatternError('RectCollection offset is forced to (0, 0)')
|
||||||
|
|
||||||
|
def set_offset(self, val: ArrayLike) -> Self:
|
||||||
|
if numpy.any(val):
|
||||||
|
raise PatternError('RectCollection offset is forced to (0, 0)')
|
||||||
|
return self
|
||||||
|
|
||||||
|
def translate(self, offset: ArrayLike) -> Self:
|
||||||
|
delta = numpy.asarray(offset, dtype=float).reshape(2)
|
||||||
|
self._rects[:, [0, 2]] += delta[0]
|
||||||
|
self._rects[:, [1, 3]] += delta[1]
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
rects: ArrayLike,
|
||||||
|
*,
|
||||||
|
offset: ArrayLike = (0.0, 0.0),
|
||||||
|
rotation: float = 0.0,
|
||||||
|
repetition: Repetition | None = None,
|
||||||
|
annotations: annotations_t = None,
|
||||||
|
raw: bool = False,
|
||||||
|
) -> None:
|
||||||
|
if raw:
|
||||||
|
assert isinstance(rects, numpy.ndarray)
|
||||||
|
self._rects = rects
|
||||||
|
self._repetition = repetition
|
||||||
|
self._annotations = annotations
|
||||||
|
else:
|
||||||
|
self.rects = rects
|
||||||
|
self.repetition = repetition
|
||||||
|
self.annotations = annotations
|
||||||
|
if rotation:
|
||||||
|
self.rotate(rotation)
|
||||||
|
if numpy.any(offset):
|
||||||
|
self.translate(offset)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_raw(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
rects: NDArray[numpy.float64],
|
||||||
|
annotations: annotations_t = None,
|
||||||
|
repetition: Repetition | None = None,
|
||||||
|
) -> Self:
|
||||||
|
new = cls.__new__(cls)
|
||||||
|
new._rects = rects
|
||||||
|
new._repetition = repetition
|
||||||
|
new._annotations = annotations
|
||||||
|
return new
|
||||||
|
|
||||||
|
@property
|
||||||
|
def polygon_vertices(self) -> Iterator[NDArray[numpy.float64]]:
|
||||||
|
for rect in self._rects:
|
||||||
|
xmin, ymin, xmax, ymax = rect
|
||||||
|
yield numpy.array([
|
||||||
|
[xmin, ymin],
|
||||||
|
[xmin, ymax],
|
||||||
|
[xmax, ymax],
|
||||||
|
[xmax, ymin],
|
||||||
|
], dtype=float)
|
||||||
|
|
||||||
|
def __deepcopy__(self, memo: dict | None = None) -> Self:
|
||||||
|
memo = {} if memo is None else memo
|
||||||
|
new = copy.copy(self)
|
||||||
|
new._rects = self._rects.copy()
|
||||||
|
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||||
|
new._annotations = copy.deepcopy(self._annotations)
|
||||||
|
return new
|
||||||
|
|
||||||
|
def _sorted_rects(self) -> NDArray[numpy.float64]:
|
||||||
|
if self._rects.shape[0] <= 1:
|
||||||
|
return self._rects
|
||||||
|
order = numpy.lexsort((self._rects[:, 3], self._rects[:, 2], self._rects[:, 1], self._rects[:, 0]))
|
||||||
|
return self._rects[order]
|
||||||
|
|
||||||
|
def __eq__(self, other: Any) -> bool:
|
||||||
|
return (
|
||||||
|
type(self) is type(other)
|
||||||
|
and numpy.array_equal(self._sorted_rects(), other._sorted_rects())
|
||||||
|
and self.repetition == other.repetition
|
||||||
|
and annotations_eq(self.annotations, other.annotations)
|
||||||
|
)
|
||||||
|
|
||||||
|
def __lt__(self, other: Shape) -> bool:
|
||||||
|
if type(self) is not type(other):
|
||||||
|
if repr(type(self)) != repr(type(other)):
|
||||||
|
return repr(type(self)) < repr(type(other))
|
||||||
|
return id(type(self)) < id(type(other))
|
||||||
|
|
||||||
|
other = cast('RectCollection', other)
|
||||||
|
self_rects = self._sorted_rects()
|
||||||
|
other_rects = other._sorted_rects()
|
||||||
|
if not numpy.array_equal(self_rects, other_rects):
|
||||||
|
min_len = min(self_rects.shape[0], other_rects.shape[0])
|
||||||
|
eq_mask = self_rects[:min_len] != other_rects[:min_len]
|
||||||
|
eq_lt = self_rects[:min_len] < other_rects[:min_len]
|
||||||
|
eq_lt_masked = eq_lt[eq_mask]
|
||||||
|
if eq_lt_masked.size > 0:
|
||||||
|
return bool(eq_lt_masked.flat[0])
|
||||||
|
return self_rects.shape[0] < other_rects.shape[0]
|
||||||
|
if self.repetition != other.repetition:
|
||||||
|
return rep2key(self.repetition) < rep2key(other.repetition)
|
||||||
|
return annotations_lt(self.annotations, other.annotations)
|
||||||
|
|
||||||
|
def to_polygons(
|
||||||
|
self,
|
||||||
|
num_vertices: int | None = None, # unused # noqa: ARG002
|
||||||
|
max_arclen: float | None = None, # unused # noqa: ARG002
|
||||||
|
) -> list[Polygon]:
|
||||||
|
return [
|
||||||
|
Polygon(
|
||||||
|
vertices=vertices,
|
||||||
|
repetition=copy.deepcopy(self.repetition),
|
||||||
|
annotations=copy.deepcopy(self.annotations),
|
||||||
|
)
|
||||||
|
for vertices in self.polygon_vertices
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_bounds_single(self) -> NDArray[numpy.float64] | None:
|
||||||
|
if self._rects.size == 0:
|
||||||
|
return None
|
||||||
|
mins = self._rects[:, :2].min(axis=0)
|
||||||
|
maxs = self._rects[:, 2:].max(axis=0)
|
||||||
|
return numpy.vstack((mins, maxs))
|
||||||
|
|
||||||
|
def rotate(self, theta: float) -> Self:
|
||||||
|
quarter_turns = int(numpy.rint(theta / (pi / 2)))
|
||||||
|
if not numpy.isclose(theta, quarter_turns * (pi / 2)):
|
||||||
|
raise PatternError('RectCollection only supports Manhattan rotations')
|
||||||
|
turns = quarter_turns % 4
|
||||||
|
if turns == 0 or self._rects.size == 0:
|
||||||
|
return self
|
||||||
|
|
||||||
|
corners = numpy.stack((
|
||||||
|
self._rects[:, [0, 1]],
|
||||||
|
self._rects[:, [0, 3]],
|
||||||
|
self._rects[:, [2, 3]],
|
||||||
|
self._rects[:, [2, 1]],
|
||||||
|
), axis=1)
|
||||||
|
flat = corners.reshape(-1, 2)
|
||||||
|
if turns == 1:
|
||||||
|
rotated = numpy.column_stack((-flat[:, 1], flat[:, 0]))
|
||||||
|
elif turns == 2:
|
||||||
|
rotated = -flat
|
||||||
|
else:
|
||||||
|
rotated = numpy.column_stack((flat[:, 1], -flat[:, 0]))
|
||||||
|
corners = rotated.reshape(corners.shape)
|
||||||
|
self._rects[:, 0] = corners[:, :, 0].min(axis=1)
|
||||||
|
self._rects[:, 1] = corners[:, :, 1].min(axis=1)
|
||||||
|
self._rects[:, 2] = corners[:, :, 0].max(axis=1)
|
||||||
|
self._rects[:, 3] = corners[:, :, 1].max(axis=1)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def mirror(self, axis: int = 0) -> Self:
|
||||||
|
if axis not in (0, 1):
|
||||||
|
raise PatternError('Axis must be 0 or 1')
|
||||||
|
if axis == 0:
|
||||||
|
self._rects[:, [1, 3]] *= -1
|
||||||
|
else:
|
||||||
|
self._rects[:, [0, 2]] *= -1
|
||||||
|
_renormalize_rects_in_place(self._rects)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def scale_by(self, c: float) -> Self:
|
||||||
|
self._rects *= c
|
||||||
|
_renormalize_rects_in_place(self._rects)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
|
||||||
|
rects = self._sorted_rects()
|
||||||
|
centers = 0.5 * (rects[:, :2] + rects[:, 2:])
|
||||||
|
offset = centers.mean(axis=0)
|
||||||
|
zeroed = rects.copy()
|
||||||
|
zeroed[:, [0, 2]] -= offset[0]
|
||||||
|
zeroed[:, [1, 3]] -= offset[1]
|
||||||
|
normed = zeroed / norm_value
|
||||||
|
return (
|
||||||
|
(type(self), normed.data.tobytes()),
|
||||||
|
(offset, 1.0, 0.0, False),
|
||||||
|
lambda: RectCollection(rects=normed * norm_value),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
if self._rects.size == 0:
|
||||||
|
return '<RectCollection r0>'
|
||||||
|
centers = 0.5 * (self._rects[:, :2] + self._rects[:, 2:])
|
||||||
|
centroid = centers.mean(axis=0)
|
||||||
|
return f'<RectCollection centroid {centroid} r{self._rects.shape[0]}>'
|
||||||
70
masque/test/test_rect_collection.py
Normal file
70
masque/test/test_rect_collection.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import copy
|
||||||
|
|
||||||
|
import numpy
|
||||||
|
import pytest
|
||||||
|
from numpy.testing import assert_allclose, assert_equal
|
||||||
|
|
||||||
|
from ..error import PatternError
|
||||||
|
from ..shapes import Polygon, RectCollection
|
||||||
|
|
||||||
|
|
||||||
|
def test_rect_collection_init_and_to_polygons() -> None:
|
||||||
|
rects = RectCollection([[10, 10, 12, 12], [0, 0, 5, 5]])
|
||||||
|
assert_equal(rects.rects, [[0, 0, 5, 5], [10, 10, 12, 12]])
|
||||||
|
|
||||||
|
polys = rects.to_polygons()
|
||||||
|
assert len(polys) == 2
|
||||||
|
assert all(isinstance(poly, Polygon) for poly in polys)
|
||||||
|
assert_equal(polys[0].vertices, [[0, 0], [0, 5], [5, 5], [5, 0]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_rect_collection_rejects_invalid_rects() -> None:
|
||||||
|
with pytest.raises(PatternError):
|
||||||
|
RectCollection([[0, 0, 1]])
|
||||||
|
with pytest.raises(PatternError):
|
||||||
|
RectCollection([[5, 0, 1, 2]])
|
||||||
|
with pytest.raises(PatternError):
|
||||||
|
RectCollection([[0, 5, 1, 2]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_rect_collection_raw_constructor_matches_public() -> None:
|
||||||
|
raw = RectCollection._from_raw(
|
||||||
|
rects=numpy.array([[10.0, 10.0, 12.0, 12.0], [0.0, 0.0, 5.0, 5.0]]),
|
||||||
|
annotations={'1': ['rects']},
|
||||||
|
)
|
||||||
|
public = RectCollection(
|
||||||
|
[[0, 0, 5, 5], [10, 10, 12, 12]],
|
||||||
|
annotations={'1': ['rects']},
|
||||||
|
)
|
||||||
|
assert raw == public
|
||||||
|
assert_equal(raw.get_bounds_single(), [[0, 0], [12, 12]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_rect_collection_manhattan_transforms() -> None:
|
||||||
|
rects = RectCollection([[0, 0, 2, 4], [10, 20, 12, 22]])
|
||||||
|
|
||||||
|
mirrored = copy.deepcopy(rects).mirror(1)
|
||||||
|
assert_equal(mirrored.rects, [[-2, 0, 0, 4], [-12, 20, -10, 22]])
|
||||||
|
|
||||||
|
scaled = copy.deepcopy(rects).scale_by(-2)
|
||||||
|
assert_equal(scaled.rects, [[-4, -8, 0, 0], [-24, -44, -20, -40]])
|
||||||
|
|
||||||
|
rotated = copy.deepcopy(rects).rotate(numpy.pi / 2)
|
||||||
|
assert_equal(rotated.rects, [[-4, 0, 0, 2], [-22, 10, -20, 12]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_rect_collection_non_manhattan_rotation_raises() -> None:
|
||||||
|
rects = RectCollection([[0, 0, 2, 4]])
|
||||||
|
with pytest.raises(PatternError, match='Manhattan rotations'):
|
||||||
|
rects.rotate(numpy.pi / 4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rect_collection_normalized_form_rebuild_is_independent() -> None:
|
||||||
|
rects = RectCollection([[0, 0, 2, 4], [10, 20, 12, 22]])
|
||||||
|
_intrinsic, extrinsic, rebuild = rects.normalized_form(2)
|
||||||
|
|
||||||
|
clone = rebuild()
|
||||||
|
clone.rects[:] = [[1, 1, 2, 2], [3, 3, 4, 4]]
|
||||||
|
|
||||||
|
assert_allclose(extrinsic[0], [6, 11.5])
|
||||||
|
assert_equal(rects.rects, [[0, 0, 2, 4], [10, 20, 12, 22]])
|
||||||
Loading…
Add table
Add a link
Reference in a new issue