Skip to content

Commit 096dfae

Browse files
committed
Uncrossing polygon functions.
This adds some utility functions, the most significant of which is `uncrossPolygon`. This can handle polygons with or without holes. Without holes, it takes a polygon in the form of a list of vertices with each vertex a list or tuple of at least two elements. For a polygon with holes, it takes a list of such polygons, where the first element in the list is the outside polygon and all other elements are the holes. If a polygon self-crosses, additional vertices will be added and part of the sequence of vertices will be revered to uncross the polygon. If the polygon contains holes, each hole is checked to make sure it doesn't cross other holes or the outline polygon. The resultant polygon will have consistently clockwise vertices (though if the original polygon has multiple loops that only have a vertex in common, not all of the polygon may be consistently oriented). Examples: - `uncrossPolygon([[0, 4], [0, 2], [2, 2], [2, 0], [0, 0], [2, 4]])` results in `[[0, 4], [2, 4], [1, 2], [2, 2], [2, 0], [0, 0], [1, 2], [0, 2]]` - `uncrossPolygon([[[0, 0], [0, 2], [2, 2], [2, 0]], [[1, 1], [1, 3], [3, 3], [3, 1]]])` results in `[[[0, 0], [0, 2], [1, 2], [1, 1], [2, 1], [2, 2], [1, 2], [1, 3], [3, 3], [3, 1], [2, 1], [2, 0]]]`
1 parent 06d8729 commit 096dfae

File tree

5 files changed

+337
-1
lines changed

5 files changed

+337
-1
lines changed

large_image/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@
2121
from . import server # Works in non-editable install
2222
from .server import tilesource
2323
from .server import cache_util
24+
from .server import util
2425
except ImportError:
2526
import server # Works in editable install
2627
from server import tilesource
2728
from server import cache_util
29+
from server import util
2830

2931
getTileSource = tilesource.getTileSource # noqa
3032

31-
__all__ = ['server', 'tilesource', 'getTileSource', 'cache_util']
33+
__all__ = ['server', 'tilesource', 'getTileSource', 'cache_util', 'util']

plugin.cmake

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ add_python_test(girderless PLUGIN large_image
114114
set_property(TEST server_large_image.girderless APPEND PROPERTY ENVIRONMENT
115115
"LARGE_IMAGE_DATA=${PROJECT_BINARY_DIR}/data/plugins/large_image")
116116

117+
add_python_test(util PLUGIN large_image)
118+
117119
add_python_test(examples PLUGIN large_image
118120
# There is a bug in cmake that fails when external data files are added to
119121
# multiple tests, so comment out the external data directive.

plugin_tests/util_test.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
#!/usr/bin/env python
2+
# -*- coding: utf-8 -*-
3+
4+
import unittest
5+
6+
7+
class LargeImageUtilTest(unittest.TestCase):
8+
def testUncrossPolygon(self):
9+
from large_image import util
10+
11+
testPairs = [( # (input, output)
12+
# Do nothing if the polygon doesn't cross
13+
[[0, 0], [0, 2], [2, 2], [2, 0]],
14+
[[0, 0], [0, 2], [2, 2], [2, 0]],
15+
), (
16+
# Always covner polygons to clockwise
17+
[[0, 0], [2, 0], [2, 2], [0, 2]],
18+
[[0, 0], [0, 2], [2, 2], [2, 0]],
19+
), (
20+
# Uncross a polygon
21+
[[0, 4], [0, 2], [2, 2], [2, 0], [0, 0], [2, 4]],
22+
[[0, 4], [2, 4], [1, 2], [2, 2], [2, 0], [0, 0], [1, 2], [0, 2]]
23+
), (
24+
# Discard degenerate polygons
25+
[[0, 4]],
26+
[],
27+
), (
28+
# Discard degenerate polygons
29+
[],
30+
[],
31+
), (
32+
# Discard degenerate polygons, even if they have valid holes
33+
[[[0, 4]],
34+
[[1, 1], [1, 3], [3, 3], [3, 1]]],
35+
[[]],
36+
), (
37+
# Merge a hole with a polygon if the hole crosses it
38+
[[[0, 0], [0, 2], [2, 2], [2, 0]],
39+
[[1, 1], [1, 3], [3, 3], [3, 1]]],
40+
[[[0, 0], [0, 2], [1, 2], [1, 1], [2, 1], [2, 2], [1, 2], [1, 3],
41+
[3, 3], [3, 1], [2, 1], [2, 0]]],
42+
), (
43+
# Keep non-crossing holes, but holes are counter-clockwise
44+
[[[0, 0], [0, 4], [4, 4], [4, 0]],
45+
[[1, 1], [1, 3], [3, 3], [3, 1]]],
46+
[[[0, 0], [0, 4], [4, 4], [4, 0]],
47+
[[3, 1], [3, 3], [1, 3], [1, 1]]]
48+
), (
49+
# Discard degenerate holes
50+
[[[0, 0], [0, 4], [4, 4], [4, 0]],
51+
[[1, 1], [1, 1], [1, 1], [1, 1]]],
52+
[[[0, 0], [0, 4], [4, 4], [4, 0]]]
53+
), (
54+
# Handle coincident lines
55+
[[0, 0], [0, 2], [0, 1], [0, 3], [3, 3], [3, 0]],
56+
[[0, 0], [0, 2], [0, 1], [0, 2], [0, 3], [3, 3], [3, 0]]
57+
), (
58+
# Handle coincident lines with a different ordering
59+
[[0, 0], [0, 3], [0, 1], [0, 2], [3, 2], [3, 0]],
60+
[[0, 0], [0, 1], [0, 2], [0, 1], [0, 2], [0, 3], [0, 2], [3, 2], [3, 0]]
61+
), (
62+
# Handle coincident lines with holes
63+
[[0, 0], [3, 0], [3, 1], [1, 1], [1, 0], [2, 0], [2, 2], [0, 2]],
64+
[[0, 0], [0, 2], [2, 2], [2, 1], [3, 1], [3, 0], [2, 0], [1, 0],
65+
[2, 0], [2, 1], [1, 1], [1, 0]]
66+
)]
67+
68+
for input, output in testPairs:
69+
self.assertEqual(util.uncrossPolygon(input), output)

server/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,4 @@
3434
# if girder is available, and we fail to import anything else, girder will
3535
# show the failure
3636
from .base import load # noqa
37+
from . import util # noqa

server/util/__init__.py

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
# General utility functions
2+
3+
from six.moves import range
4+
5+
6+
def distance2dSquared(pt1, pt2):
7+
"""
8+
Get the square of the Euclidean 2D distance between two points.
9+
10+
:param pt1: The first point. This is an array with at least two numeric
11+
elements.
12+
:param pt2: The second point.
13+
:returns: The distance squared.
14+
"""
15+
dx = pt1[0] - pt2[0]
16+
dy = pt1[1] - pt2[1]
17+
return dx * dx + dy * dy
18+
19+
20+
def distance2dToLineSquared(pt, line1, line2):
21+
"""
22+
Get the square of the Euclidean 2D distance between a point and a line
23+
segment.
24+
25+
:param pt: The point.
26+
:param line1: One end of the line.
27+
:param line2: The other end of the line.
28+
:returns: The distance squared.
29+
"""
30+
dx = line2[0] - line1[0]
31+
dy = line2[1] - line1[1]
32+
lengthSquared = dx * dx + dy * dy
33+
t = 0
34+
if lengthSquared:
35+
t = float((pt[0] - line1[0]) * dx + (pt[1] - line1[1]) * dy) / lengthSquared
36+
t = max(0, min(1, t))
37+
return distance2dSquared(pt, [line1[0] + t * dx, line1[1] + t * dy])
38+
39+
40+
def triangleTwiceSignedArea2d(pt1, pt2, pt3):
41+
"""
42+
Get twice the signed area of a 2d triangle.
43+
44+
:param pt1: A vertex. This is an array with at least two numeric elements.
45+
:param pt2: A vertex.
46+
:param pt3: A vertex.
47+
:returns: Twice the signed area.
48+
"""
49+
return (pt2[1] - pt1[1]) * (pt3[0] - pt2[0]) - (pt2[0] - pt1[0]) * (pt3[1] - pt2[1])
50+
51+
52+
def crossLineSegments2d(seg1pt1, seg1pt2, seg2pt1, seg2pt2):
53+
"""
54+
Determine if two line segments cross. They are not considered crossing if
55+
they share a vertex. They are crossing if either of one segment's
56+
vertices are colinear with the other segment.
57+
58+
:param line1pt1: one endpoint on the first segment.
59+
:param line1pt2: the other endpoint on the first segment.
60+
:param line2pt1: one endpoint on the second segment.
61+
:param line2pt2: the other endpoint on the second segment.
62+
:returns: True uf the segments cross.
63+
"""
64+
# If the segments don't have any overlap in x or y, they can't cross
65+
if ((seg1pt1[0] > seg2pt1[0] and seg1pt1[0] > seg2pt2[0] and
66+
seg1pt2[0] > seg2pt1[0] and seg1pt2[0] > seg2pt2[0]) or
67+
(seg1pt1[0] < seg2pt1[0] and seg1pt1[0] < seg2pt2[0] and
68+
seg1pt2[0] < seg2pt1[0] and seg1pt2[0] < seg2pt2[0]) or
69+
(seg1pt1[1] > seg2pt1[1] and seg1pt1[1] > seg2pt2[1] and
70+
seg1pt2[1] > seg2pt1[1] and seg1pt2[1] > seg2pt2[1]) or
71+
(seg1pt1[1] < seg2pt1[1] and seg1pt1[1] < seg2pt2[1] and
72+
seg1pt2[1] < seg2pt1[1] and seg1pt2[1] < seg2pt2[1])):
73+
return False
74+
# If any vertex is in common, it is not considered crossing
75+
if (seg1pt1 == seg2pt1 or seg1pt1 == seg2pt2 or seg1pt2 == seg2pt1 or
76+
seg1pt2 == seg2pt2):
77+
return False
78+
# If the lines cross, the signed area of the triangles formed between one
79+
# segment and the other's vertices will have different signs. By using
80+
# > 0, colinear points are crossing.
81+
if (triangleTwiceSignedArea2d(seg1pt1, seg1pt2, seg2pt1) *
82+
triangleTwiceSignedArea2d(seg1pt1, seg1pt2, seg2pt2) > 0 or
83+
triangleTwiceSignedArea2d(seg2pt1, seg2pt2, seg1pt1) *
84+
triangleTwiceSignedArea2d(seg2pt1, seg2pt2, seg1pt2) > 0):
85+
return False
86+
return True
87+
88+
89+
def lineIntersection2d(line1pt1, line1pt2, line2pt1, line2pt2):
90+
"""
91+
Given lines defined by pairs of points, find the point of intersection.
92+
93+
:param line1pt1: a point on the first line.
94+
:param line1pt2: a second point on the first line.
95+
:param line2pt1: a point on the second line.
96+
:param line2pt2: a second point on the second line.
97+
:returns: the point of intersection, or None if the lines are parallel.
98+
"""
99+
line1dx, line1dy = line1pt1[0] - line1pt2[0], line1pt1[1] - line1pt2[1]
100+
line2dx, line2dy = line2pt1[0] - line2pt2[0], line2pt1[1] - line2pt2[1]
101+
det = float(line1dx * line2dy - line1dy * line2dx)
102+
if not det:
103+
return
104+
return [
105+
((line1pt1[0] * line1pt2[1] - line1pt1[1] * line1pt2[0]) * line2dx -
106+
(line2pt1[0] * line2pt2[1] - line2pt1[1] * line2pt2[0]) * line1dx) / det,
107+
((line1pt1[0] * line1pt2[1] - line1pt1[1] * line1pt2[0]) * line2dy -
108+
(line2pt1[0] * line2pt2[1] - line2pt1[1] * line2pt2[0]) * line1dy) / det,
109+
]
110+
111+
112+
def lineIntersectionOrEndpoint(line1pt1, line1pt2, line2pt1, line2pt2):
113+
"""
114+
Given lines defined by pairs of points, find the point of intersection. If
115+
the two lines are coincident, return a endpoint that is not in common.
116+
117+
:param line1pt1: a point on the first line.
118+
:param line1pt2: a second point on the first line.
119+
:param line2pt1: a point on the second line.
120+
:param line2pt2: a second point on the second line.
121+
:returns: the point of intersection, or None if the lines are parallel and
122+
do not overlap.
123+
"""
124+
crossPt = lineIntersection2d(line1pt1, line1pt2, line2pt1, line2pt2)
125+
if crossPt:
126+
return crossPt
127+
for pt in (line1pt1, line1pt2):
128+
if pt != line2pt1 and pt != line2pt2:
129+
if not distance2dToLineSquared(pt, line2pt1, line2pt2):
130+
return pt
131+
for pt in (line2pt1, line2pt2):
132+
if pt != line1pt1 and pt != line1pt2:
133+
if not distance2dToLineSquared(pt, line1pt1, line1pt2):
134+
return pt
135+
# The two lines segments do not overlap
136+
137+
138+
def uncrossPolygonWithoutHoles(vertices):
139+
"""
140+
Given a list of vertices ensure that the polygon does not cross itself.
141+
Repeated vertices are removed. If resulting polygon has 2 or less vertices
142+
(this can only happen if so specified or there are duplicate vertices), an
143+
empty list is returned.
144+
145+
:param vertices: a list of vertices of the polygon. Each vertex is a list
146+
of at least 2 coordinates (only the first two values are considered).
147+
:returns: vertices: a list of vertices.
148+
"""
149+
if len(vertices) <= 2:
150+
return []
151+
# Add the first point at the end so we can skip some modulo work
152+
pts = vertices[:] + vertices[0:1]
153+
idx1 = 0
154+
# Iterate through all segments but the last
155+
while idx1 < len(pts) - 2:
156+
seg1pt1 = pts[idx1]
157+
seg1pt2 = pts[idx1 + 1]
158+
idx2 = idx1 + 1
159+
# Iterate through the remaining segments
160+
while idx2 < len(pts) - 1: # - 1 sicne we duplicated the first point
161+
seg2pt1 = pts[idx2]
162+
seg2pt2 = pts[idx2 + 1]
163+
# Check if the two segments cross
164+
if crossLineSegments2d(seg1pt1, seg1pt2, seg2pt1, seg2pt2):
165+
# If crossing, add the crossing point and reverse the loop
166+
crossPt = lineIntersectionOrEndpoint(seg1pt1, seg1pt2, seg2pt1, seg2pt2)
167+
pts = (pts[:idx1 + 1] + [crossPt] + pts[idx2:idx1:-1] +
168+
[crossPt] + pts[idx2 + 1:])
169+
break
170+
idx2 += 1
171+
else:
172+
idx1 += 1
173+
# Get rid of duplicates except the duplicated first point
174+
pts = [pt for idx, pt in enumerate(pts) if not idx or pt != pts[idx - 1]]
175+
# Ensure clockwiseness (if counterclockwise, reverse points).
176+
# This still leaves the possibility that the original polygon contained
177+
# two separable loops that are in different orientations. For instance, if
178+
# the polygon is two triangles that share a single vertex and no edges, the
179+
# two triangles would ideally be in opposite orientations if one is inside
180+
# the other and the same orientation if they are not. Adjusting this is
181+
# currently beyond the scope of this function.
182+
if sum((pts[idx + 1][0] - pt[0]) * (pts[idx + 1][1] + pt[1])
183+
for idx, pt in enumerate(pts[:-1])) < 0:
184+
pts = pts[::-1]
185+
# Remove duplicated first point
186+
pts = pts[:-1]
187+
return pts
188+
189+
190+
def mergeCrossingPolygons(poly1, poly2):
191+
"""
192+
Given two polygons, check if any edge from the first polygon crosses any
193+
edge in the second polygon. If so, merge the two polygons by adding the
194+
crossing point, combining the second polygon at this point, and passing the
195+
result through `uncrossPolygonWithoutHoles`.
196+
197+
:param poly1: a list of vertices forming an uncrossed polygon without
198+
holes.
199+
:param poly2: a list of vertices forming an uncrossed polygon without
200+
holes.
201+
:returns: None if the polygons do not cross, or a new polygon if they do.
202+
"""
203+
for idx1, seg1pt1 in enumerate(poly1):
204+
seg1pt2 = poly1[(idx1 + 1) % len(poly1)]
205+
for idx2, seg2pt1 in enumerate(poly2):
206+
seg2pt2 = poly2[(idx2 + 1) % len(poly2)]
207+
if crossLineSegments2d(seg1pt1, seg1pt2, seg2pt1, seg2pt2):
208+
# If crossing, combine the polygons at the crossing point
209+
crossPt = lineIntersectionOrEndpoint(seg1pt1, seg1pt2, seg2pt1, seg2pt2)
210+
poly = (poly1[:idx1 + 1] + [crossPt] +
211+
(poly2[idx2 + 1:] + poly2[:idx2 + 1])[::-1] +
212+
[crossPt] + poly1[idx1 + 1:])
213+
return uncrossPolygonWithoutHoles(poly)
214+
215+
216+
def uncrossPolygon(vertices):
217+
"""
218+
Given a list of vertices, or a list of lists of vertices where the first
219+
entry if the polygon and subsequent entries are holes, ensure that the
220+
polygon does not cross itself. Each hole is uncrossed on its own. If two
221+
holes (or a hole and a polygon) cross each other, they are
222+
joined into a single more complicated polygon. Repeated vertices and
223+
degenerate polygons (2 or less vertices) are removed.
224+
225+
:param vertices: a list of vertices of the polygon. Each vertex is a list
226+
of at least 2 coordinates (only the first two values are considered).
227+
Alternately, a list of lists of vertices, where the first entry is the
228+
polygon and subsequent entries are holes.
229+
:returns: vertices: a list of vertices or a list of lists of vertices.
230+
This has the same depth as the original argument.
231+
"""
232+
if not len(vertices) or not len(vertices[0]):
233+
return vertices
234+
if not isinstance(vertices[0][0], list):
235+
return uncrossPolygonWithoutHoles(vertices)
236+
# uncross the outer polygon and all holes
237+
polygons = [uncrossPolygonWithoutHoles(pts) for pts in vertices]
238+
# If the containing polygon is degenerate, return an empty set
239+
if not len(polygons[0]):
240+
return [[]]
241+
# discard degenerate holes
242+
polygons = [polygon for polygon in polygons if len(polygon)]
243+
# For each polygon, check if it crosses any other polygon. If it does,
244+
# join them together at the first crossing point and uncross the result.
245+
pidx1 = 0
246+
while pidx1 < len(polygons) - 1:
247+
poly1 = polygons[pidx1]
248+
pidx2 = pidx1 + 1
249+
while pidx2 < len(polygons):
250+
poly2 = polygons[pidx2]
251+
crossed = mergeCrossingPolygons(poly1, poly2)
252+
if crossed:
253+
polygons[pidx1] = crossed
254+
del polygons[pidx2]
255+
break
256+
pidx2 += 1
257+
else:
258+
pidx1 += 1
259+
# reverse all holes so that they are opposite direction as the main polygon
260+
for idx in range(1, len(polygons)):
261+
polygons[idx] = polygons[idx][::-1]
262+
return polygons

0 commit comments

Comments
 (0)