Skip to content

Commit e730bcf

Browse files
authored
Remove shapely dependency (#204)
* Remove shapely dependency * fixes * remove print * Add tests
1 parent b18c412 commit e730bcf

File tree

5 files changed

+274
-16
lines changed

5 files changed

+274
-16
lines changed

nucleus/metrics/filters.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@
22

33
from nucleus.prediction import PredictionList
44

5-
from .polygon_utils import BoxOrPolygonAnnotation, polygon_annotation_to_shape
5+
from .polygon_utils import (
6+
BoxOrPolygonAnnotation,
7+
polygon_annotation_to_geometry,
8+
)
69

710

811
def polygon_area_filter(
912
polygons: List[BoxOrPolygonAnnotation], min_area: float, max_area: float
1013
) -> List[BoxOrPolygonAnnotation]:
1114
filter_fn = (
1215
lambda polygon: min_area
13-
<= polygon_annotation_to_shape(polygon)
16+
<= polygon_annotation_to_geometry(polygon).signed_area
1417
<= max_area
1518
)
1619
return list(filter(filter_fn, polygons))

nucleus/metrics/geometry.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
from typing import List, Tuple, Union
2+
3+
import numpy as np
4+
5+
TOLERANCE = 1e-8
6+
7+
8+
class GeometryPoint:
9+
def __init__(self, xy: Union[Tuple[float, float], np.ndarray]):
10+
self.xy = np.array(xy)
11+
self.x = xy[0]
12+
self.y = xy[1]
13+
14+
def __repr__(self) -> str:
15+
return f"GeometryPoint(xy=({self.xy[0]}, {self.xy[1]})"
16+
17+
def __add__(self, p: "GeometryPoint") -> "GeometryPoint":
18+
return GeometryPoint(self.xy + p.xy)
19+
20+
def __sub__(self, p: "GeometryPoint") -> "GeometryPoint":
21+
return GeometryPoint(self.xy - p.xy)
22+
23+
def __rmul__(self, scalar: float) -> "GeometryPoint":
24+
return GeometryPoint(self.xy * scalar)
25+
26+
def __mul__(self, scalar: float) -> "GeometryPoint":
27+
return GeometryPoint(self.xy * scalar)
28+
29+
# Operator @
30+
def __matmul__(self, p: "GeometryPoint") -> float:
31+
return self.xy @ p.xy
32+
33+
def length(self) -> float:
34+
return np.linalg.norm(self.xy)
35+
36+
def cmp(self, p: "GeometryPoint") -> float:
37+
return np.abs(self.xy - p.xy).sum()
38+
39+
40+
class GeometryPolygon:
41+
def __init__(self, points: List[GeometryPoint]):
42+
self.points = points
43+
44+
points_x = np.array([point.x for point in points])
45+
points_y = np.array([point.y for point in points])
46+
self.signed_area = (
47+
points_x @ np.roll(points_y, 1) - points_x @ np.roll(points_y, -1)
48+
) / 2
49+
self.area = np.abs(self.signed_area)
50+
51+
def __len__(self):
52+
return len(self.points)
53+
54+
def __getitem__(self, idx):
55+
return self.points[idx]
56+
57+
def __repr__(self) -> str:
58+
points = ", ".join([str(point) for point in self.points])
59+
return f"GeometryPolygon({points})"
60+
61+
62+
Segment = Tuple[GeometryPoint, GeometryPoint]
63+
64+
65+
# alpha * a1 + (1 - alpha) * a2 = beta * b1 + (1 - beta) * b2
66+
def segment_intersection(
67+
segment1: Segment, segment2: Segment
68+
) -> Tuple[float, float, GeometryPoint]:
69+
a1, a2 = segment1
70+
b1, b2 = segment2
71+
x2_x2 = b2.x - a2.x
72+
y2_y2 = b2.y - a2.y
73+
x1x2 = a1.x - a2.x
74+
y1y2 = a1.y - a2.y
75+
y1_y2_ = b1.y - b2.y
76+
x1_x2_ = b1.x - b2.x
77+
78+
if np.abs(y1_y2_ * x1x2 - x1_x2_ * y1y2) < TOLERANCE:
79+
beta = 1.0
80+
else:
81+
beta = (x2_x2 * y1y2 - y2_y2 * x1x2) / (y1_y2_ * x1x2 - x1_x2_ * y1y2)
82+
83+
if x1x2 == 0:
84+
alpha = (y2_y2 + y1_y2_ * beta) / (y1y2 + TOLERANCE)
85+
else:
86+
alpha = (x2_x2 + x1_x2_ * beta) / (x1x2 + TOLERANCE)
87+
88+
return alpha, beta, alpha * a1 + (1 - alpha) * a2
89+
90+
91+
def convex_polygon_intersection_area(
92+
polygon_a: GeometryPolygon, polygon_b: GeometryPolygon
93+
) -> float:
94+
# pylint: disable=R0912
95+
sa = polygon_a.signed_area
96+
sb = polygon_b.signed_area
97+
if sa * sb < 0:
98+
sign = -1
99+
else:
100+
sign = 1
101+
na = len(polygon_a)
102+
nb = len(polygon_b)
103+
ps = [] # point set
104+
for i in range(na):
105+
a1 = polygon_a[i - 1]
106+
a2 = polygon_a[i]
107+
flag = False
108+
sum_s = 0
109+
for j in range(nb):
110+
b1 = polygon_b[j - 1]
111+
b2 = polygon_b[j]
112+
sum_s += np.abs(GeometryPolygon([a1, b1, b2]).signed_area)
113+
114+
if np.abs(np.abs(sum_s) - np.abs(sb)) < TOLERANCE:
115+
flag = True
116+
117+
if flag:
118+
ps.append(a1)
119+
for j in range(nb):
120+
b1 = polygon_b[j - 1]
121+
b2 = polygon_b[j]
122+
a, b, p = segment_intersection((a1, a2), (b1, b2))
123+
if 0 < a < 1 and 0 < b < 1:
124+
ps.append(p)
125+
126+
for i in range(nb):
127+
a1 = polygon_b[i - 1]
128+
a2 = polygon_b[i]
129+
flag = False
130+
sum_s = 0
131+
for j in range(na):
132+
b1 = polygon_a[j - 1]
133+
b2 = polygon_a[j]
134+
sum_s += np.abs(GeometryPolygon([a1, b1, b2]).signed_area)
135+
if np.abs(np.abs(sum_s) - np.abs(sa)) < TOLERANCE:
136+
flag = True
137+
if flag:
138+
ps.append(a1)
139+
140+
def unique(ar):
141+
res = []
142+
for i, _ in enumerate(ar):
143+
if _.cmp(ar[i - 1]) > TOLERANCE:
144+
res.append(_)
145+
146+
return res
147+
148+
ps = sorted(ps, key=lambda x: (x.x + TOLERANCE * x.y))
149+
ps = unique(ps)
150+
151+
if len(ps) == 0:
152+
return 0
153+
154+
tmp = ps[0]
155+
156+
res = []
157+
res.append(tmp)
158+
ps = sorted(
159+
ps[1:],
160+
key=lambda x: -(
161+
(x - tmp) @ GeometryPoint((0, 1)) / (x - tmp).length()
162+
),
163+
)
164+
res.extend(ps)
165+
166+
return GeometryPolygon(res).signed_area * sign
167+
168+
169+
def polygon_intersection_area(
170+
polygon_a: GeometryPolygon, polygon_b: GeometryPolygon
171+
) -> float:
172+
na = len(polygon_a)
173+
nb = len(polygon_b)
174+
res = 0.0
175+
for i in range(1, na - 1):
176+
sa = [polygon_a[0], polygon_a[i], polygon_a[i + 1]]
177+
for j in range(1, nb - 1):
178+
sb = [polygon_b[0], polygon_b[j], polygon_b[j + 1]]
179+
tmp = convex_polygon_intersection_area(
180+
GeometryPolygon(sa), GeometryPolygon(sb)
181+
)
182+
res += tmp
183+
184+
return np.abs(res)

nucleus/metrics/polygon_utils.py

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@
44

55
import numpy as np
66
from scipy.optimize import linear_sum_assignment
7-
from shapely.geometry import Polygon
87

98
from nucleus.annotation import BoxAnnotation, PolygonAnnotation
109
from nucleus.prediction import BoxPrediction, PolygonPrediction
1110

1211
from .base import MetricResult
1312
from .errors import PolygonAnnotationTypeError
13+
from .geometry import GeometryPoint, GeometryPolygon, polygon_intersection_area
1414

1515
BoxOrPolygonPrediction = TypeVar(
1616
"BoxOrPolygonPrediction", BoxPrediction, PolygonPrediction
@@ -20,31 +20,40 @@
2020
)
2121

2222

23-
def polygon_annotation_to_shape(
23+
def polygon_annotation_to_geometry(
2424
annotation: BoxOrPolygonAnnotation,
25-
) -> Polygon:
25+
) -> GeometryPolygon:
2626
if isinstance(annotation, BoxAnnotation):
2727
xmin = annotation.x - annotation.width / 2
2828
xmax = annotation.x + annotation.width / 2
2929
ymin = annotation.y - annotation.height / 2
3030
ymax = annotation.y + annotation.height / 2
31-
return Polygon(
32-
[(xmin, ymin), (xmax, ymin), (xmax, ymax), (xmin, ymax)]
33-
)
31+
points = [
32+
GeometryPoint((xmin, ymin)),
33+
GeometryPoint((xmax, ymin)),
34+
GeometryPoint((xmax, ymax)),
35+
GeometryPoint((xmin, ymax)),
36+
]
37+
return GeometryPolygon(points)
3438
elif isinstance(annotation, PolygonAnnotation):
35-
return Polygon([(point.x, point.y) for point in annotation.vertices])
39+
return GeometryPolygon(
40+
[
41+
GeometryPoint((point.x, point.y))
42+
for point in annotation.vertices
43+
]
44+
)
3645
else:
3746
raise PolygonAnnotationTypeError()
3847

3948

40-
def _iou(annotation: Polygon, prediction: Polygon) -> float:
41-
intersection = annotation.intersection(prediction).area
49+
def _iou(annotation: GeometryPolygon, prediction: GeometryPolygon) -> float:
50+
intersection = polygon_intersection_area(annotation, prediction)
4251
union = annotation.area + prediction.area - intersection
4352
return intersection / max(union, sys.float_info.epsilon)
4453

4554

4655
def _iou_matrix(
47-
annotations: List[Polygon], predictions: List[Polygon]
56+
annotations: List[GeometryPolygon], predictions: List[GeometryPolygon]
4857
) -> np.ndarray:
4958
iou_matrix = np.empty((len(predictions), len(annotations)))
5059
for i, prediction in enumerate(predictions):
@@ -65,9 +74,13 @@ def _iou_assignments_for_same_reference_id(
6574
len(reference_ids) <= 1
6675
), "Expected annotations and predictions to have same reference ID."
6776

68-
# Convert annotation and predictions to shapely.geometry.Polygon objects
69-
polygon_annotations = list(map(polygon_annotation_to_shape, annotations))
70-
polygon_predictions = list(map(polygon_annotation_to_shape, predictions))
77+
# Convert annotation and predictions to GeometryPolygon objects
78+
polygon_annotations = list(
79+
map(polygon_annotation_to_geometry, annotations)
80+
)
81+
polygon_predictions = list(
82+
map(polygon_annotation_to_geometry, predictions)
83+
)
7184

7285
# Compute IoU matrix and set IoU values below the threshold to 0.
7386
iou_matrix = _iou_matrix(polygon_annotations, polygon_predictions)

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ pydantic = "^1.8.2"
4343
isort = "^5.10.1"
4444
numpy = "^1.19.5"
4545
scipy = "^1.5.4"
46-
Shapely = "^1.8.0"
4746

4847
[tool.poetry.dev-dependencies]
4948
poetry = "^1.1.5"

tests/metrics/test_geometry.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import numpy as np
2+
import pytest
3+
4+
from nucleus.metrics.geometry import (
5+
GeometryPoint,
6+
GeometryPolygon,
7+
convex_polygon_intersection_area,
8+
polygon_intersection_area,
9+
segment_intersection,
10+
)
11+
12+
RECTANGLE1 = GeometryPolygon(
13+
[
14+
GeometryPoint((0, 0)),
15+
GeometryPoint((100, 0)),
16+
GeometryPoint((100, 100)),
17+
GeometryPoint((0, 100)),
18+
]
19+
)
20+
21+
RECTANGLE2 = GeometryPolygon(
22+
[
23+
GeometryPoint((50, 50)),
24+
GeometryPoint((50, 150)),
25+
GeometryPoint((150, 150)),
26+
GeometryPoint((150, 50)),
27+
]
28+
)
29+
30+
SEGMENT1 = (GeometryPoint((0, 0)), GeometryPoint((0, 50)))
31+
32+
SEGMENT2 = (GeometryPoint((-25, 25)), GeometryPoint((25, 25)))
33+
34+
35+
def test_segment_intersection():
36+
alpha, beta, intersection = segment_intersection(SEGMENT1, SEGMENT2)
37+
assert alpha == pytest.approx(0.5)
38+
assert beta == pytest.approx(0.5)
39+
assert intersection.x == pytest.approx(0)
40+
assert intersection.y == pytest.approx(25)
41+
42+
43+
def test_convex_polygon_intersection_area():
44+
intersection = np.abs(
45+
convex_polygon_intersection_area(RECTANGLE1, RECTANGLE2)
46+
)
47+
assert intersection == pytest.approx(2500)
48+
49+
50+
def test_polygon_intersection_area():
51+
intersection = polygon_intersection_area(RECTANGLE1, RECTANGLE2)
52+
assert intersection == pytest.approx(2500)
53+
54+
55+
def test_polygon_area():
56+
assert RECTANGLE1.signed_area == -10000
57+
assert RECTANGLE1.area == 10000
58+
assert RECTANGLE2.signed_area == 10000
59+
assert RECTANGLE2.area == 10000

0 commit comments

Comments
 (0)