Skip to content

Commit

Permalink
Add geometry.polyline.self_intersects and is_convex
Browse files Browse the repository at this point in the history
  • Loading branch information
mrtj committed May 13, 2022
1 parent 226adcb commit 078d74e
Show file tree
Hide file tree
Showing 6 changed files with 155 additions and 38 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pip-delete-this-directory.txt
.cache
nosetests.xml
coverage.xml
lcov.info

# Translations
*.mo
Expand Down
28 changes: 14 additions & 14 deletions backpack/annotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -449,28 +449,28 @@ def add_marker(self, marker: MarkerAnnotation, context: np.ndarray) -> None:
marker.style, cv2.MARKER_DIAMOND
)
cv2.drawMarker(
context,
OpenCVImageAnnotationDriver.scale(marker.point, context),
self._color_to_cv2(marker.color),
markerType
img=context,
position=OpenCVImageAnnotationDriver.scale(marker.point, context),
color=self._color_to_cv2(marker.color),
markerType=markerType
)

def add_line(self, line_anno: LineAnnotation, context: Any) -> None:
cv2.line(
context,
OpenCVImageAnnotationDriver.scale(line_anno.line.pt1, context),
OpenCVImageAnnotationDriver.scale(line_anno.line.pt2, context),
self._color_to_cv2(line_anno.color),
line_anno.thickness
img=context,
pt1=OpenCVImageAnnotationDriver.scale(line_anno.line.pt1, context),
pt2=OpenCVImageAnnotationDriver.scale(line_anno.line.pt2, context),
color=self._color_to_cv2(line_anno.color),
thickness=line_anno.thickness
)

def add_polyline(self, polyline_anno: PolyLineAnnotation, context: Any) -> None:
pts = [OpenCVImageAnnotationDriver.scale(pt, context) for pt in polyline_anno.polyline.points]
pts = [np.array(pts, dtype=np.int32)]
cv2.polylines(
context,
pts,
polyline_anno.polyline.closed,
self._color_to_cv2(polyline_anno.color),
polyline_anno.thickness
img=context,
pts=pts,
isClosed=polyline_anno.polyline.closed,
color=self._color_to_cv2(polyline_anno.color),
thickness=polyline_anno.thickness
)
86 changes: 77 additions & 9 deletions backpack/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import dataclasses
from dataclasses import dataclass
import math
import itertools

class PointMeta(type):
@classmethod
Expand All @@ -28,7 +29,8 @@ class Point(metaclass=PointMeta):

@staticmethod
def counterclockwise(pt1: 'Point', pt2: 'Point', pt3: 'Point') -> bool:
''' Determines if the three points form a counterclockwise angle.
''' Determines if the three points form a counterclockwise angle. If two points are
equal, or the three points are collinear, this method returns `True`.
Args:
pt1: The first point
Expand All @@ -38,7 +40,8 @@ def counterclockwise(pt1: 'Point', pt2: 'Point', pt3: 'Point') -> bool:
Returns:
`True` if the points form a counterclockwise angle
'''
return (pt2.x - pt1.x) * (pt3.y - pt1.y) > (pt3.x - pt1.x) * (pt2.y - pt1.y)
d21, d31 = pt2 - pt1, pt3 - pt1
return d21.x * d31.y >= d31.x * d21.y

def distance(self, other: 'Point') -> float:
''' Calculates the distance between this and an other point.
Expand All @@ -49,8 +52,40 @@ def distance(self, other: 'Point') -> float:
Returns:
The distance between this and an other point.
'''
dx, dy = self.x - other.x, self.y - other.y
return math.sqrt(dx * dx + dy * dy)
d = self - other
return math.sqrt(d.x * d.x + d.y * d.y)

@classmethod
def _check_arg(cls, arg, method_name):
if not isinstance(arg, cls):
raise TypeError(
f"unsupported operand type(s) for {method_name}: "
f"'{cls.__name__}' and '{type(arg).__name__}'"
)

def __add__(self, other: 'Point') -> 'Point':
''' Adds two points as if they were vectors.
Arg:
other: the other point
Returns:
A new point, the sum of the two points as if they were vectors.
'''
Point._check_arg(other, '+')
return Point(self.x + other.x, self.y + other.y)

def __sub__(self, other: 'Point') -> 'Point':
''' Subtracts two points as if they were vectors.
Arg:
other: the other point
Returns:
A new point, the difference of the two points as if they were vectors.
'''
Point._check_arg(other, '-')
return Point(self.x - other.x, self.y - other.y)


@dataclass(frozen=True)
Expand Down Expand Up @@ -114,7 +149,7 @@ def __post_init__(self, pt1, pt2):
object.__setattr__(self, 'pt_min', Point(min(pt1.x, pt2.x), min(pt1.y, pt2.y)))
object.__setattr__(self, 'pt_max', Point(max(pt1.x, pt2.x), max(pt1.y, pt2.y)))

def hasinside(self, pt: Point) -> bool:
def has_inside(self, pt: Point) -> bool:
''' Determines if a point is inside this rectangle.
Args:
Expand Down Expand Up @@ -159,7 +194,7 @@ class PolyLine:
closed : bool = True
''' `True` if the polyline is closed. '''

lines: List[Point] = dataclasses.field(init=False)
lines: List[Line] = dataclasses.field(init=False)
''' The line segments of this PolyLine '''

boundingbox: Rectangle = dataclasses.field(init=False)
Expand Down Expand Up @@ -187,7 +222,7 @@ def __post_init__(self):
maxy = max(pt.y for pt in self.points)
object.__setattr__(self, 'boundingbox', Rectangle(Point(minx, miny), Point(maxx, maxy)))

def hasinside(self, point: Point) -> bool:
def has_inside(self, point: Point) -> bool:
''' Determines if a point is inside this closed `PolyLine`.
This implementation uses the `ray casting algorithm`_.
Expand All @@ -202,9 +237,42 @@ def hasinside(self, point: Point) -> bool:
`True` if the point is inside this closed `PolyLine`.
'''
if not self.closed:
raise ValueError('PolyLine.hasinside works only for closed polylines.')
if not self.boundingbox.hasinside(point):
raise ValueError('PolyLine.has_inside works only for closed polylines.')
if not self.boundingbox.has_inside(point):
return False
ray = Line(Point(self.boundingbox.pt_min.x - 0.01, self.boundingbox.pt_min.y), point)
n_ints = sum(1 if ray.intersects(line) else 0 for line in self.lines)
return True if n_ints % 2 == 1 else False

def self_intersects(self) -> bool:
''' Determines this PolyLine self-intersects. '''
return any(
l1.intersects(l2)
for idx, l1 in enumerate(self.lines) for l2 in self.lines[idx + 2:]
)

def is_convex(self) -> bool:
''' Determines if the polygon formed from this closed PolyLine is convex.
The result of this method is undefined for complex (self-intersecting) polygons.
'''
if not self.closed:
raise ValueError('PolyLine.is_convex works only for closed polylines.')

if len(self.points) < 4:
return True

it0 = self.points
it1 = itertools.islice(itertools.cycle(self.points), 1, None)
it2 = itertools.islice(itertools.cycle(self.points), 2, None)

sign = None
for pt0, pt1, pt2 in zip(it0, it1, it2):
d1 = pt1 - pt0
d2 = pt2 - pt1
xprod = d1.x * d2.y - d1.y * d2.x
if sign is None:
sign = xprod > 0
elif sign != (xprod > 0):
return False
return True
10 changes: 5 additions & 5 deletions tests/test_annotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,11 @@ def test_line(self):
img.shape = [200, 100, 3]
self.driver.render(annotations=[TEST_LINE], context=img)
mock_cv2.line.assert_called_once_with(
img,
OpenCVImageAnnotationDriver.scale(TEST_LINE.line.pt1, img),
OpenCVImageAnnotationDriver.scale(TEST_LINE.line.pt2, img),
OpenCVImageAnnotationDriver.DEFAULT_COLOR,
TEST_LINE.thickness
img=img,
pt1=OpenCVImageAnnotationDriver.scale(TEST_LINE.line.pt1, img),
pt2=OpenCVImageAnnotationDriver.scale(TEST_LINE.line.pt2, img),
color=OpenCVImageAnnotationDriver.DEFAULT_COLOR,
thickness=TEST_LINE.thickness
)

def test_invalid(self):
Expand Down
67 changes: 57 additions & 10 deletions tests/test_geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ def test_counterclockwise(self):
pt3 = Point(1, 1)
self.assertTrue(Point.counterclockwise(pt1, pt2, pt3))
self.assertFalse(Point.counterclockwise(pt3, pt2, pt1))
pt4 = Point(2, 0)
self.assertTrue(Point.counterclockwise(pt1, pt2, pt4), msg='Collinear points')
self.assertTrue(Point.counterclockwise(pt1, pt1, pt2), msg='Same points')

def test_distance(self):
pt1 = Point(0, 3)
Expand All @@ -25,6 +28,19 @@ class DummyPoint:
self.assertFalse(isinstance({'x': 0, 'y': 0}, Point))
self.assertFalse(isinstance('foobar', Point))

def test_add(self):
pt1 = Point(2, 3)
pt2 = Point(4, 5)
self.assertEqual(pt1 + pt2, Point(6, 8))
with self.assertRaises(TypeError) as ctx:
pt1 + 'foo'
self.assertEqual(str(ctx.exception), "unsupported operand type(s) for +: 'Point' and 'str'")

def test_sub(self):
pt1 = Point(4, 7)
pt2 = Point(2, 3)
self.assertEqual(pt1 - pt2, Point(2, 4))


class TestLine(unittest.TestCase):

Expand Down Expand Up @@ -55,7 +71,6 @@ class TestRectangle(unittest.TestCase):
def setUp(self) -> None:
super().setUp()
self.rect = Rectangle(self.pt00, self.pt11)
print(self.rect)

def test_init(self):
self.assertEqual(self.rect.pt_min, self.pt00)
Expand All @@ -70,9 +85,9 @@ def test_init(self):
'Rectangle arguments "pt1" and "pt2" must be Point objects.'
)

def test_hasinside(self):
self.assertTrue(self.rect.hasinside(Point(1, 1)))
self.assertFalse(self.rect.hasinside(Point(4, 4)))
def test_has_inside(self):
self.assertTrue(self.rect.has_inside(Point(1, 1)))
self.assertFalse(self.rect.has_inside(Point(4, 4)))

def test_center(self) -> None:
self.assertEqual(self.rect.center, Point(2, 1))
Expand All @@ -83,6 +98,7 @@ def test_base(self) -> None:
def test_size(self) -> None:
self.assertEqual(self.rect.size, (4, 2))


class TestPolyLine(unittest.TestCase):

pt1, pt2, pt3, pt4 = Point(-4, 0), Point(0, 4), Point(4, 0), Point(0, -4)
Expand Down Expand Up @@ -118,10 +134,41 @@ def test_boundingbox(self) -> None:
expected_bb = Rectangle(Point(-4, -4), Point(4, 4))
self.assertEqual(self.poly_closed.boundingbox, expected_bb)

def test_hasinside(self) -> None:
self.assertTrue(self.poly_closed.hasinside(Point(0, 0)))
self.assertFalse(self.poly_closed.hasinside(Point(5, 5)))
self.assertFalse(self.poly_closed.hasinside(Point(3, 3)))
def test_has_inside(self) -> None:
self.assertTrue(self.poly_closed.has_inside(Point(0, 0)))
self.assertFalse(self.poly_closed.has_inside(Point(5, 5)))
self.assertFalse(self.poly_closed.has_inside(Point(3, 3)))
with self.assertRaises(ValueError) as ctx:
self.poly_open.hasinside(Point(0, 0))
self.assertEqual(str(ctx.exception), 'PolyLine.hasinside works only for closed polylines.')
self.poly_open.has_inside(Point(0, 0))
self.assertEqual(str(ctx.exception), 'PolyLine.has_inside works only for closed polylines.')

def test_self_intersects(self) -> None:
square = PolyLine([Point(0, 0), Point(1, 0), Point(1, 1), Point(0, 1)], closed=True)
self.assertFalse(square.self_intersects())
cross = PolyLine([Point(0, 0), Point(1, 0), Point(0, 1), Point(1, 1)], closed=True)
self.assertTrue(cross.self_intersects())

def test_is_convex(self) -> None:

with self.subTest('open polygon raises'):
with self.assertRaises(ValueError) as ctx:
PolyLine([Point(0, 0), Point(1, 0), Point(0, 1)], closed=False).is_convex()
self.assertEqual(
str(ctx.exception),
'PolyLine.is_convex works only for closed polylines.'
)

with self.subTest('triangle is convex'):
triangle = PolyLine([Point(0, 0), Point(1, 0), Point(0, 1)], closed=True)
self.assertTrue(triangle.is_convex())

with self.subTest('square is convex'):
square = PolyLine([Point(0, 0), Point(1, 0), Point(1, 1), Point(0, 1)], closed=True)
self.assertTrue(square.is_convex())

with self.subTest('concave shape'):
concave = PolyLine(
[Point(0, 0), Point(2, 0), Point(2, 2), Point(1, 1), Point(0, 2)],
closed=True
)
self.assertFalse(concave.is_convex())
1 change: 1 addition & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ commands =
coverage run --source=backpack -m unittest discover
coverage html -d build
coverage json -o build/coverage.json
coverage lcov -o lcov.info

[gh-actions]
python =
Expand Down

0 comments on commit 078d74e

Please sign in to comment.