coralnet-toolbox 0.0.73__py2.py3-none-any.whl → 0.0.74__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- coralnet_toolbox/Annotations/QtAnnotation.py +28 -69
- coralnet_toolbox/Annotations/QtMaskAnnotation.py +408 -0
- coralnet_toolbox/Annotations/QtMultiPolygonAnnotation.py +72 -56
- coralnet_toolbox/Annotations/QtPatchAnnotation.py +165 -216
- coralnet_toolbox/Annotations/QtPolygonAnnotation.py +497 -353
- coralnet_toolbox/Annotations/QtRectangleAnnotation.py +126 -116
- coralnet_toolbox/CoralNet/QtDownload.py +2 -1
- coralnet_toolbox/Explorer/QtExplorer.py +16 -14
- coralnet_toolbox/Explorer/QtSettingsWidgets.py +114 -82
- coralnet_toolbox/IO/QtExportTagLabAnnotations.py +30 -10
- coralnet_toolbox/IO/QtImportTagLabAnnotations.py +21 -15
- coralnet_toolbox/IO/QtOpenProject.py +46 -78
- coralnet_toolbox/IO/QtSaveProject.py +18 -43
- coralnet_toolbox/MachineLearning/ExportDataset/QtBase.py +1 -1
- coralnet_toolbox/MachineLearning/ImportDataset/QtBase.py +42 -22
- coralnet_toolbox/MachineLearning/VideoInference/QtBase.py +0 -4
- coralnet_toolbox/QtEventFilter.py +11 -0
- coralnet_toolbox/QtImageWindow.py +117 -68
- coralnet_toolbox/QtLabelWindow.py +13 -1
- coralnet_toolbox/QtMainWindow.py +5 -27
- coralnet_toolbox/QtProgressBar.py +52 -27
- coralnet_toolbox/Rasters/RasterTableModel.py +8 -8
- coralnet_toolbox/SAM/QtDeployPredictor.py +10 -0
- coralnet_toolbox/SeeAnything/QtDeployGenerator.py +779 -161
- coralnet_toolbox/SeeAnything/QtDeployPredictor.py +86 -149
- coralnet_toolbox/Tools/QtCutSubTool.py +18 -2
- coralnet_toolbox/Tools/QtResizeSubTool.py +19 -2
- coralnet_toolbox/Tools/QtSAMTool.py +72 -50
- coralnet_toolbox/Tools/QtSeeAnythingTool.py +8 -5
- coralnet_toolbox/Tools/QtSelectTool.py +27 -3
- coralnet_toolbox/Tools/QtSubtractSubTool.py +66 -0
- coralnet_toolbox/Tools/__init__.py +2 -0
- coralnet_toolbox/__init__.py +1 -1
- coralnet_toolbox/utilities.py +137 -47
- coralnet_toolbox-0.0.74.dist-info/METADATA +375 -0
- {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.74.dist-info}/RECORD +40 -38
- coralnet_toolbox-0.0.73.dist-info/METADATA +0 -341
- {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.74.dist-info}/WHEEL +0 -0
- {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.74.dist-info}/entry_points.txt +0 -0
- {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.74.dist-info}/licenses/LICENSE.txt +0 -0
- {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.74.dist-info}/top_level.txt +0 -0
@@ -2,19 +2,17 @@ import warnings
|
|
2
2
|
|
3
3
|
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
4
4
|
|
5
|
-
import cv2
|
6
5
|
import math
|
7
|
-
import numpy as np
|
8
6
|
|
9
7
|
from rasterio.windows import Window
|
10
8
|
|
11
|
-
from shapely.ops import split
|
9
|
+
from shapely.ops import split, unary_union
|
12
10
|
from shapely.geometry import Point, Polygon, LineString
|
13
11
|
|
14
12
|
from PyQt5.QtCore import Qt, QPointF
|
15
|
-
from PyQt5.QtWidgets import QGraphicsScene,
|
13
|
+
from PyQt5.QtWidgets import QGraphicsScene, QGraphicsPathItem
|
16
14
|
from PyQt5.QtGui import (QPixmap, QColor, QPen, QBrush, QPolygonF,
|
17
|
-
QPainter, QRegion, QImage)
|
15
|
+
QPainter, QRegion, QImage, QPainterPath)
|
18
16
|
|
19
17
|
from coralnet_toolbox.Annotations.QtAnnotation import Annotation
|
20
18
|
from coralnet_toolbox.Annotations.QtMultiPolygonAnnotation import MultiPolygonAnnotation
|
@@ -38,36 +36,67 @@ class PolygonAnnotation(Annotation):
|
|
38
36
|
image_path: str,
|
39
37
|
label_id: str,
|
40
38
|
transparency: int = 128,
|
41
|
-
show_msg: bool = False
|
39
|
+
show_msg: bool = False,
|
40
|
+
holes: list = None):
|
42
41
|
super().__init__(short_label_code, long_label_code, color, image_path, label_id, transparency, show_msg)
|
43
42
|
|
44
43
|
self.center_xy = QPointF(0, 0)
|
45
44
|
self.cropped_bbox = (0, 0, 0, 0)
|
46
45
|
self.annotation_size = 0
|
46
|
+
|
47
|
+
# Initialize holes, ensuring it's always a list
|
48
|
+
self.holes = holes if holes is not None else []
|
47
49
|
|
50
|
+
# Set the main polygon points and calculate initial properties
|
48
51
|
self.set_precision(points, True)
|
49
52
|
self.set_centroid()
|
50
53
|
self.set_cropped_bbox()
|
51
54
|
|
52
55
|
def set_precision(self, points: list, reduce: bool = True):
|
53
56
|
"""
|
54
|
-
Set the precision of the points
|
57
|
+
Set the precision of the outer points and all inner holes.
|
55
58
|
|
56
59
|
Args:
|
57
|
-
points: List of QPointF vertices
|
58
|
-
reduce: Whether to round coordinates to
|
60
|
+
points: List of QPointF vertices for the outer boundary.
|
61
|
+
reduce: Whether to round coordinates to a set number of decimal places.
|
59
62
|
"""
|
60
|
-
#
|
63
|
+
# Process and assign the outer boundary points
|
61
64
|
if reduce:
|
62
|
-
points = [QPointF(round(
|
65
|
+
self.points = [QPointF(round(p.x(), 6), round(p.y(), 6)) for p in points]
|
66
|
+
else:
|
67
|
+
self.points = points
|
63
68
|
|
64
|
-
|
69
|
+
# Process each list of points for the inner holes, if any
|
70
|
+
if self.holes and reduce:
|
71
|
+
processed_holes = []
|
72
|
+
for hole in self.holes:
|
73
|
+
processed_hole = [QPointF(round(p.x(), 6), round(p.y(), 6)) for p in hole]
|
74
|
+
processed_holes.append(processed_hole)
|
75
|
+
self.holes = processed_holes
|
65
76
|
|
66
77
|
def set_centroid(self):
|
67
|
-
"""
|
68
|
-
|
69
|
-
|
70
|
-
|
78
|
+
"""
|
79
|
+
Calculate the true geometric centroid of the polygon, accounting for any holes.
|
80
|
+
"""
|
81
|
+
try:
|
82
|
+
# Simple average of the outer points for stability
|
83
|
+
centroid_x = sum(point.x() for point in self.points) / len(self.points)
|
84
|
+
centroid_y = sum(point.y() for point in self.points) / len(self.points)
|
85
|
+
self.center_xy = QPointF(centroid_x, centroid_y)
|
86
|
+
|
87
|
+
except Exception:
|
88
|
+
# Convert the QPointF lists to coordinate tuples for Shapely
|
89
|
+
shell_coords = [(p.x(), p.y()) for p in self.points]
|
90
|
+
holes_coords = [[(p.x(), p.y()) for p in hole] for hole in self.holes]
|
91
|
+
|
92
|
+
# Create a Shapely polygon with its shell and holes
|
93
|
+
shapely_polygon = Polygon(shell=shell_coords, holes=holes_coords)
|
94
|
+
|
95
|
+
# Get the true centroid from the Shapely object
|
96
|
+
centroid = shapely_polygon.centroid
|
97
|
+
|
98
|
+
# Update the annotation's center_xy with the new coordinates
|
99
|
+
self.center_xy = QPointF(centroid.x, centroid.y)
|
71
100
|
|
72
101
|
def set_cropped_bbox(self):
|
73
102
|
"""Calculate the bounding box of the polygon defined by the points."""
|
@@ -79,47 +108,120 @@ class PolygonAnnotation(Annotation):
|
|
79
108
|
self.annotation_size = int(max(max_x - min_x, max_y - min_y))
|
80
109
|
|
81
110
|
def contains_point(self, point: QPointF) -> bool:
|
82
|
-
"""
|
83
|
-
polygon
|
84
|
-
|
111
|
+
"""
|
112
|
+
Check if the given point is inside the polygon, excluding any holes.
|
113
|
+
"""
|
114
|
+
try:
|
115
|
+
# Convert the QPointF lists to coordinate tuples for Shapely
|
116
|
+
shell_coords = [(p.x(), p.y()) for p in self.points]
|
117
|
+
holes_coords = [[(p.x(), p.y()) for p in hole] for hole in self.holes]
|
118
|
+
|
119
|
+
# Create a Shapely polygon with its shell and holes
|
120
|
+
shapely_polygon = Polygon(shell=shell_coords, holes=holes_coords)
|
121
|
+
|
122
|
+
# Convert the input QPointF to a Shapely Point
|
123
|
+
shapely_point = Point(point.x(), point.y())
|
124
|
+
|
125
|
+
# Return Shapely's boolean result for the containment check
|
126
|
+
return shapely_polygon.contains(shapely_point)
|
127
|
+
|
128
|
+
except Exception:
|
129
|
+
# If Shapely fails, fall back to the original implementation which
|
130
|
+
# checks against the outer boundary only.
|
131
|
+
polygon = QPolygonF(self.points)
|
132
|
+
return polygon.containsPoint(point, Qt.OddEvenFill)
|
85
133
|
|
86
134
|
def get_centroid(self):
|
87
135
|
"""Get the centroid of the annotation."""
|
88
136
|
return (float(self.center_xy.x()), float(self.center_xy.y()))
|
89
137
|
|
90
138
|
def get_area(self):
|
91
|
-
"""
|
139
|
+
"""
|
140
|
+
Calculate the net area of the polygon (outer area - inner holes' area).
|
141
|
+
"""
|
142
|
+
# A shape with fewer than 3 points has no area.
|
92
143
|
if len(self.points) < 3:
|
93
144
|
return 0.0
|
94
145
|
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
146
|
+
try:
|
147
|
+
# Convert the QPointF lists to coordinate tuples for Shapely
|
148
|
+
shell_coords = [(p.x(), p.y()) for p in self.points]
|
149
|
+
holes_coords = [[(p.x(), p.y()) for p in hole] for hole in self.holes]
|
150
|
+
|
151
|
+
# Create a Shapely polygon with its shell and holes
|
152
|
+
shapely_polygon = Polygon(shell=shell_coords, holes=holes_coords)
|
153
|
+
|
154
|
+
# Return the net area calculated by Shapely
|
155
|
+
return shapely_polygon.area
|
156
|
+
|
157
|
+
except Exception:
|
158
|
+
# If Shapely fails (e.g., due to invalid geometry), fall back to
|
159
|
+
# calculating the gross area of the outer polygon using the shoelace formula.
|
160
|
+
# This is the original implementation.
|
161
|
+
area = 0.0
|
162
|
+
n = len(self.points)
|
163
|
+
for i in range(n):
|
164
|
+
j = (i + 1) % n
|
165
|
+
area += self.points[i].x() * self.points[j].y()
|
166
|
+
area -= self.points[j].x() * self.points[i].y()
|
167
|
+
return abs(area) / 2.0
|
103
168
|
|
104
169
|
def get_perimeter(self):
|
105
|
-
"""
|
170
|
+
"""
|
171
|
+
Calculate the total perimeter of the polygon (outer boundary + all hole boundaries).
|
172
|
+
"""
|
173
|
+
# A shape with fewer than 2 points has no length.
|
106
174
|
if len(self.points) < 2:
|
107
175
|
return 0.0
|
108
176
|
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
perimeter
|
118
|
-
|
177
|
+
try:
|
178
|
+
# Convert the QPointF lists to coordinate tuples for Shapely
|
179
|
+
shell_coords = [(p.x(), p.y()) for p in self.points]
|
180
|
+
holes_coords = [[(p.x(), p.y()) for p in hole] for hole in self.holes]
|
181
|
+
|
182
|
+
# Create a Shapely polygon with its shell and holes
|
183
|
+
shapely_polygon = Polygon(shell=shell_coords, holes=holes_coords)
|
184
|
+
|
185
|
+
# Return the total perimeter (length) calculated by Shapely
|
186
|
+
return shapely_polygon.length
|
187
|
+
|
188
|
+
except Exception:
|
189
|
+
# If Shapely fails, fall back to calculating the perimeter of the
|
190
|
+
# outer boundary only. This is the original implementation.
|
191
|
+
perimeter = 0.0
|
192
|
+
n = len(self.points)
|
193
|
+
for i in range(n):
|
194
|
+
j = (i + 1) % n
|
195
|
+
dx = self.points[i].x() - self.points[j].x()
|
196
|
+
dy = self.points[i].y() - self.points[j].y()
|
197
|
+
distance = math.sqrt(dx * dx + dy * dy)
|
198
|
+
perimeter += distance
|
199
|
+
return perimeter
|
119
200
|
|
120
201
|
def get_polygon(self):
|
121
202
|
"""Get the polygon representation of this polygon annotation."""
|
122
203
|
return QPolygonF(self.points)
|
204
|
+
|
205
|
+
def get_painter_path(self) -> QPainterPath:
|
206
|
+
"""
|
207
|
+
Get a QPainterPath representation of the annotation, including holes.
|
208
|
+
|
209
|
+
This is the correct object to use for rendering complex polygons.
|
210
|
+
"""
|
211
|
+
path = QPainterPath()
|
212
|
+
|
213
|
+
# 1. Add the outer boundary to the path
|
214
|
+
path.addPolygon(QPolygonF(self.points))
|
215
|
+
|
216
|
+
# 2. Add each of the inner holes to the path
|
217
|
+
for hole in self.holes:
|
218
|
+
path.addPolygon(QPolygonF(hole))
|
219
|
+
|
220
|
+
# 3. Set the fill rule, which tells the painter to treat
|
221
|
+
# overlapping polygons as holes.
|
222
|
+
path.setFillRule(Qt.OddEvenFill)
|
223
|
+
|
224
|
+
return path
|
123
225
|
|
124
226
|
def get_bounding_box_top_left(self):
|
125
227
|
"""Get the top-left corner of the annotation's bounding box."""
|
@@ -130,61 +232,68 @@ class PolygonAnnotation(Annotation):
|
|
130
232
|
return QPointF(self.cropped_bbox[2], self.cropped_bbox[3])
|
131
233
|
|
132
234
|
def get_cropped_image_graphic(self):
|
133
|
-
"""
|
235
|
+
"""
|
236
|
+
Get the cropped image with the polygon and its holes correctly masked.
|
237
|
+
"""
|
134
238
|
if self.cropped_image is None:
|
135
239
|
return None
|
136
240
|
|
137
|
-
# Create
|
138
|
-
|
139
|
-
|
241
|
+
# --- Create the painter path with translated coordinates ---
|
242
|
+
# The path needs coordinates relative to the cropped image's top-left corner.
|
243
|
+
offset_x = self.cropped_bbox[0]
|
244
|
+
offset_y = self.cropped_bbox[1]
|
245
|
+
|
246
|
+
path = QPainterPath()
|
247
|
+
path.setFillRule(Qt.OddEvenFill)
|
140
248
|
|
141
|
-
#
|
142
|
-
|
143
|
-
|
144
|
-
painter.setBrush(QBrush(Qt.white)) # White fill for the mask area
|
145
|
-
painter.setPen(Qt.NoPen)
|
249
|
+
# Add the translated outer boundary
|
250
|
+
outer_boundary = QPolygonF([QPointF(p.x() - offset_x, p.y() - offset_y) for p in self.points])
|
251
|
+
path.addPolygon(outer_boundary)
|
146
252
|
|
147
|
-
#
|
148
|
-
|
149
|
-
|
253
|
+
# Add the translated holes
|
254
|
+
for hole in self.holes:
|
255
|
+
inner_boundary = QPolygonF([QPointF(p.x() - offset_x, p.y() - offset_y) for p in hole])
|
256
|
+
path.addPolygon(inner_boundary)
|
257
|
+
# ---------------------------------------------------------
|
150
258
|
|
151
|
-
# Create a
|
152
|
-
|
259
|
+
# Create a QImage for the mask with a transparent background
|
260
|
+
masked_image = QImage(self.cropped_image.size(), QImage.Format_ARGB32)
|
261
|
+
masked_image.fill(Qt.transparent)
|
153
262
|
|
154
|
-
#
|
155
|
-
|
156
|
-
|
263
|
+
# Create a painter to draw the path onto the mask
|
264
|
+
mask_painter = QPainter(masked_image)
|
265
|
+
mask_painter.setRenderHint(QPainter.Antialiasing)
|
266
|
+
mask_painter.setBrush(QBrush(Qt.white)) # White fill for the mask area
|
267
|
+
mask_painter.setPen(Qt.NoPen)
|
268
|
+
mask_painter.drawPath(path) # Use drawPath instead of drawPolygon
|
269
|
+
mask_painter.end()
|
157
270
|
|
158
|
-
# Convert the mask
|
159
|
-
# We want the inside of the polygon to show the image, so we DON'T use MaskInColor
|
271
|
+
# Convert the mask to a QRegion for clipping
|
160
272
|
mask_pixmap = QPixmap.fromImage(masked_image)
|
161
273
|
mask_bitmap = mask_pixmap.createMaskFromColor(Qt.white, Qt.MaskOutColor)
|
162
|
-
|
163
|
-
# Convert bitmap to region for clipping
|
164
274
|
mask_region = QRegion(mask_bitmap)
|
165
275
|
|
166
|
-
#
|
276
|
+
# --- Compose the final graphic ---
|
167
277
|
cropped_image_graphic = QPixmap(self.cropped_image.size())
|
168
|
-
|
169
|
-
# First draw the entire original image at 50% opacity (for area outside polygon)
|
170
278
|
result_painter = QPainter(cropped_image_graphic)
|
171
279
|
result_painter.setRenderHint(QPainter.Antialiasing)
|
172
|
-
|
280
|
+
|
281
|
+
# Draw the full original image at 50% opacity
|
282
|
+
result_painter.setOpacity(0.5)
|
173
283
|
result_painter.drawPixmap(0, 0, self.cropped_image)
|
174
284
|
|
175
|
-
#
|
176
|
-
result_painter.setOpacity(1.0)
|
285
|
+
# Draw the full-opacity image inside the masked region (the annotation area)
|
286
|
+
result_painter.setOpacity(1.0)
|
177
287
|
result_painter.setClipRegion(mask_region)
|
178
288
|
result_painter.drawPixmap(0, 0, self.cropped_image)
|
179
289
|
|
180
|
-
# Draw the
|
290
|
+
# Draw the outline of the path (outer and inner boundaries)
|
181
291
|
pen = QPen(Qt.black)
|
182
|
-
pen.setStyle(Qt.SolidLine)
|
183
|
-
pen.setWidth(1)
|
292
|
+
pen.setStyle(Qt.SolidLine)
|
293
|
+
pen.setWidth(1)
|
184
294
|
result_painter.setPen(pen)
|
185
295
|
result_painter.setClipping(False) # Disable clipping for the outline
|
186
|
-
result_painter.
|
187
|
-
|
296
|
+
result_painter.drawPath(path) # Use drawPath for the outline as well
|
188
297
|
result_painter.end()
|
189
298
|
|
190
299
|
return cropped_image_graphic
|
@@ -214,52 +323,100 @@ class PolygonAnnotation(Annotation):
|
|
214
323
|
self.annotationUpdated.emit(self) # Notify update
|
215
324
|
|
216
325
|
def create_graphics_item(self, scene: QGraphicsScene):
|
217
|
-
"""
|
218
|
-
|
219
|
-
|
220
|
-
|
326
|
+
"""
|
327
|
+
Create all graphics items for the annotation and add them to the scene.
|
328
|
+
|
329
|
+
This now uses QGraphicsPathItem to correctly render holes.
|
330
|
+
"""
|
331
|
+
# Get the complete shape (with holes) as a QPainterPath.
|
332
|
+
path = self.get_painter_path()
|
333
|
+
|
334
|
+
# Use a QGraphicsPathItem, the correct item for a QPainterPath.
|
335
|
+
self.graphics_item = QGraphicsPathItem(path)
|
336
|
+
|
337
|
+
# Call the parent class method to handle grouping, styling, and adding to the scene.
|
221
338
|
super().create_graphics_item(scene)
|
222
339
|
|
223
340
|
def update_graphics_item(self):
|
224
|
-
"""
|
225
|
-
|
226
|
-
|
227
|
-
|
341
|
+
"""
|
342
|
+
Update the graphical representation of the polygon annotation.
|
343
|
+
|
344
|
+
This now uses QGraphicsPathItem to correctly re-render holes.
|
345
|
+
"""
|
346
|
+
# Get the complete shape (with holes) as a QPainterPath.
|
347
|
+
path = self.get_painter_path()
|
348
|
+
|
349
|
+
# Use a QGraphicsPathItem to correctly represent the shape.
|
350
|
+
self.graphics_item = QGraphicsPathItem(path)
|
351
|
+
|
352
|
+
# Call the parent class method to handle rebuilding the graphics group.
|
228
353
|
super().update_graphics_item()
|
229
354
|
|
230
355
|
def update_polygon(self, delta):
|
231
356
|
"""
|
232
|
-
Simplify or densify the polygon based on wheel movement.
|
357
|
+
Simplify or densify the polygon and its holes based on wheel movement.
|
233
358
|
"""
|
234
|
-
|
235
|
-
|
236
|
-
# Adjust tolerance based on wheel direction
|
359
|
+
# Determine which function to use based on the delta
|
237
360
|
if delta < 0:
|
238
361
|
# Simplify: increase tolerance (less detail)
|
239
362
|
self.tolerance = min(self.tolerance + 0.05, 2.0)
|
240
|
-
|
363
|
+
process_function = lambda pts: simplify_polygon(pts, self.tolerance)
|
241
364
|
elif delta > 0:
|
242
365
|
# Densify: decrease segment length (more detail)
|
243
|
-
|
366
|
+
process_function = densify_polygon
|
244
367
|
else:
|
245
|
-
|
368
|
+
# No change
|
369
|
+
return
|
246
370
|
|
247
|
-
|
248
|
-
|
371
|
+
# --- Process the Outer Boundary ---
|
372
|
+
xy_points = [(p.x(), p.y()) for p in self.points]
|
373
|
+
updated_coords = process_function(xy_points)
|
374
|
+
|
375
|
+
# --- Process Each of the Inner Holes ---
|
376
|
+
updated_holes = []
|
377
|
+
if self.holes:
|
378
|
+
for hole in self.holes:
|
379
|
+
xy_hole_points = [(p.x(), p.y()) for p in hole]
|
380
|
+
updated_hole_coords = process_function(xy_hole_points)
|
381
|
+
updated_holes.append([QPointF(x, y) for x, y in updated_hole_coords])
|
382
|
+
|
383
|
+
# Update the holes attribute before calling set_precision
|
384
|
+
self.holes = updated_holes
|
385
|
+
|
386
|
+
# --- Finalize and Update ---
|
387
|
+
# Convert outer boundary points and set precision for all points
|
388
|
+
final_points = [QPointF(x, y) for x, y in updated_coords]
|
389
|
+
self.set_precision(final_points)
|
390
|
+
|
391
|
+
# Recalculate properties and refresh the graphics
|
249
392
|
self.set_centroid()
|
250
393
|
self.set_cropped_bbox()
|
394
|
+
self.update_graphics_item()
|
395
|
+
self.annotationUpdated.emit(self)
|
251
396
|
|
252
397
|
def update_location(self, new_center_xy: QPointF):
|
253
|
-
"""
|
398
|
+
"""
|
399
|
+
Update the location of the annotation by moving it to a new center point.
|
400
|
+
This now moves the outer boundary and all holes together.
|
401
|
+
"""
|
254
402
|
# Clear the machine confidence
|
255
403
|
self.update_user_confidence(self.label)
|
256
404
|
|
257
|
-
#
|
405
|
+
# Calculate the distance to move (delta)
|
258
406
|
delta = QPointF(round(new_center_xy.x() - self.center_xy.x(), 2),
|
259
407
|
round(new_center_xy.y() - self.center_xy.y(), 2))
|
260
408
|
|
409
|
+
# Move the outer boundary points
|
261
410
|
new_points = [point + delta for point in self.points]
|
262
411
|
|
412
|
+
# Move all points for each hole
|
413
|
+
new_holes = []
|
414
|
+
for hole in self.holes:
|
415
|
+
moved_hole = [point + delta for point in hole]
|
416
|
+
new_holes.append(moved_hole)
|
417
|
+
self.holes = new_holes
|
418
|
+
|
419
|
+
# Update precision, recalculate properties, and refresh the graphics
|
263
420
|
self.set_precision(new_points)
|
264
421
|
self.set_centroid()
|
265
422
|
self.set_cropped_bbox()
|
@@ -268,22 +425,20 @@ class PolygonAnnotation(Annotation):
|
|
268
425
|
|
269
426
|
def update_annotation_size(self, delta: float):
|
270
427
|
"""
|
271
|
-
Grow/shrink the polygon by scaling
|
272
|
-
delta > 1: grow, 0 < delta < 1: shrink.
|
273
|
-
The amount of change is reduced for smoother interaction.
|
428
|
+
Grow/shrink the polygon and its holes by scaling vertices radially from the centroid.
|
274
429
|
"""
|
275
430
|
self.update_user_confidence(self.label)
|
276
431
|
|
277
432
|
if len(self.points) < 3:
|
278
433
|
return
|
279
434
|
|
280
|
-
#
|
281
|
-
|
282
|
-
|
435
|
+
# 1. Use the true geometric centroid as the pivot for scaling.
|
436
|
+
# This is correctly calculated by the new set_centroid() method.
|
437
|
+
centroid_x = self.center_xy.x()
|
438
|
+
centroid_y = self.center_xy.y()
|
283
439
|
|
284
|
-
# Determine scale factor
|
285
|
-
|
286
|
-
step = 0.01 # You can adjust this value for finer or coarser changes
|
440
|
+
# 2. Determine the scale factor (this logic remains the same).
|
441
|
+
step = 0.01 # Adjust for finer or coarser changes
|
287
442
|
if delta > 1.0:
|
288
443
|
scale = 1.0 + step
|
289
444
|
elif delta < 1.0:
|
@@ -291,7 +446,7 @@ class PolygonAnnotation(Annotation):
|
|
291
446
|
else:
|
292
447
|
scale = 1.0
|
293
448
|
|
294
|
-
#
|
449
|
+
# 3. Scale the outer boundary points.
|
295
450
|
new_points = []
|
296
451
|
for p in self.points:
|
297
452
|
dx = p.x() - centroid_x
|
@@ -299,258 +454,152 @@ class PolygonAnnotation(Annotation):
|
|
299
454
|
new_x = centroid_x + dx * scale
|
300
455
|
new_y = centroid_y + dy * scale
|
301
456
|
new_points.append(QPointF(new_x, new_y))
|
302
|
-
|
457
|
+
|
458
|
+
# 4. Scale all points for each hole using the same logic.
|
459
|
+
new_holes = []
|
460
|
+
for hole in self.holes:
|
461
|
+
scaled_hole = []
|
462
|
+
for p in hole:
|
463
|
+
dx = p.x() - centroid_x
|
464
|
+
dy = p.y() - centroid_y
|
465
|
+
new_x = centroid_x + dx * scale
|
466
|
+
new_y = centroid_y + dy * scale
|
467
|
+
scaled_hole.append(QPointF(new_x, new_y))
|
468
|
+
new_holes.append(scaled_hole)
|
469
|
+
self.holes = new_holes
|
470
|
+
|
471
|
+
# 5. Update precision, recalculate properties, and refresh the graphics.
|
303
472
|
self.set_precision(new_points)
|
304
473
|
self.set_centroid()
|
305
474
|
self.set_cropped_bbox()
|
306
475
|
self.update_graphics_item()
|
307
476
|
self.annotationUpdated.emit(self)
|
308
477
|
|
309
|
-
def resize(self, handle, new_pos):
|
310
|
-
"""
|
311
|
-
|
478
|
+
def resize(self, handle: str, new_pos: QPointF):
|
479
|
+
"""
|
480
|
+
Resize the annotation by moving a specific handle (vertex) to a new position.
|
481
|
+
The handle format is updated to support holes: 'point_{poly_index}_{vertex_index}'.
|
482
|
+
"""
|
312
483
|
self.update_user_confidence(self.label)
|
313
484
|
|
314
|
-
|
315
|
-
|
316
|
-
new_points = self.points.copy()
|
317
|
-
point_index = int(handle.split("_")[1])
|
318
|
-
|
319
|
-
# Move only the selected point
|
320
|
-
new_points[point_index] = new_pos
|
485
|
+
if not handle.startswith("point_"):
|
486
|
+
return
|
321
487
|
|
322
|
-
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
|
488
|
+
try:
|
489
|
+
# Parse the new handle format: "point_outer_5" or "point_0_2"
|
490
|
+
_, poly_index_str, vertex_index_str = handle.split("_")
|
491
|
+
vertex_index = int(vertex_index_str)
|
492
|
+
|
493
|
+
# --- Modify the correct list of points ---
|
494
|
+
if poly_index_str == "outer":
|
495
|
+
# Handle resizing the outer boundary
|
496
|
+
if 0 <= vertex_index < len(self.points):
|
497
|
+
new_points = self.points.copy()
|
498
|
+
new_points[vertex_index] = new_pos
|
499
|
+
# set_precision will handle updating self.points
|
500
|
+
self.set_precision(new_points)
|
501
|
+
else:
|
502
|
+
# Handle resizing one of the holes
|
503
|
+
poly_index = int(poly_index_str)
|
504
|
+
if 0 <= poly_index < len(self.holes):
|
505
|
+
if 0 <= vertex_index < len(self.holes[poly_index]):
|
506
|
+
# Create a copy, modify it, and update the list of holes
|
507
|
+
new_hole = self.holes[poly_index].copy()
|
508
|
+
new_hole[vertex_index] = new_pos
|
509
|
+
self.holes[poly_index] = new_hole
|
510
|
+
# set_precision will handle the holes list in-place
|
511
|
+
self.set_precision(self.points)
|
512
|
+
|
513
|
+
except (ValueError, IndexError):
|
514
|
+
# Fail gracefully if the handle format is invalid
|
515
|
+
return
|
327
516
|
|
328
|
-
|
329
|
-
|
517
|
+
# --- Recalculate properties and refresh the graphics ---
|
518
|
+
self.set_centroid()
|
519
|
+
self.set_cropped_bbox()
|
520
|
+
self.update_graphics_item()
|
521
|
+
self.annotationUpdated.emit(self)
|
330
522
|
|
331
523
|
@classmethod
|
332
524
|
def combine(cls, annotations: list):
|
333
|
-
"""
|
525
|
+
"""
|
526
|
+
Combine annotations using Shapely's union operation.
|
527
|
+
Returns a single PolygonAnnotation (if merged) or a MultiPolygonAnnotation (if disjoint).
|
528
|
+
"""
|
334
529
|
if not annotations:
|
335
530
|
return None
|
336
|
-
|
337
531
|
if len(annotations) == 1:
|
338
532
|
return annotations[0]
|
339
533
|
|
340
|
-
|
341
|
-
|
342
|
-
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
|
359
|
-
|
360
|
-
|
361
|
-
|
362
|
-
|
363
|
-
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
370
|
-
|
371
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
375
|
-
|
376
|
-
|
377
|
-
|
378
|
-
|
379
|
-
|
380
|
-
|
381
|
-
|
382
|
-
|
383
|
-
# Check for intersection
|
384
|
-
intersection = cv2.bitwise_and(mask1, mask2)
|
385
|
-
if np.any(intersection):
|
386
|
-
has_overlap = True
|
387
|
-
|
388
|
-
# Fallback to point-in-polygon check if no overlap detected yet
|
389
|
-
if not has_overlap:
|
390
|
-
# Check if any point of polygon i is inside polygon j
|
391
|
-
for point in annotations[i].points:
|
392
|
-
if annotations[j].contains_point(point):
|
393
|
-
has_overlap = True
|
394
|
-
break
|
395
|
-
|
396
|
-
if not has_overlap:
|
397
|
-
# Check if any point of polygon j is inside polygon i
|
398
|
-
for point in annotations[j].points:
|
399
|
-
if annotations[i].contains_point(point):
|
400
|
-
has_overlap = True
|
401
|
-
break
|
402
|
-
|
403
|
-
# If overlap is found, add an edge between i and j in the graph
|
404
|
-
if has_overlap:
|
405
|
-
overlap_graph[i].add(j)
|
406
|
-
overlap_graph[j].add(i)
|
407
|
-
|
408
|
-
# Check if there are any overlaps at all
|
409
|
-
has_any_overlap = any(len(neighbors) > 0 for neighbors in overlap_graph.values())
|
410
|
-
|
411
|
-
if not has_any_overlap:
|
412
|
-
# No intersections at all - create MultiPolygonAnnotation
|
413
|
-
polygons = [
|
414
|
-
cls(
|
415
|
-
points=anno.points,
|
416
|
-
short_label_code=anno.label.short_label_code,
|
417
|
-
long_label_code=anno.label.long_label_code,
|
418
|
-
color=anno.label.color,
|
419
|
-
image_path=anno.image_path,
|
420
|
-
label_id=anno.label.id
|
421
|
-
) for anno in annotations
|
422
|
-
]
|
423
|
-
new_anno = MultiPolygonAnnotation(
|
424
|
-
polygons=polygons,
|
425
|
-
short_label_code=annotations[0].label.short_label_code,
|
426
|
-
long_label_code=annotations[0].label.long_label_code,
|
427
|
-
color=annotations[0].label.color,
|
428
|
-
image_path=annotations[0].image_path,
|
429
|
-
label_id=annotations[0].label.id
|
430
|
-
)
|
431
|
-
# Transfer rasterio source if applicable
|
432
|
-
if all(hasattr(anno, 'rasterio_src') and anno.rasterio_src is not None for anno in annotations):
|
433
|
-
if len(set(id(anno.rasterio_src) for anno in annotations)) == 1:
|
434
|
-
new_anno.rasterio_src = annotations[0].rasterio_src
|
435
|
-
new_anno.create_cropped_image(new_anno.rasterio_src)
|
436
|
-
return new_anno
|
437
|
-
|
438
|
-
# Check if all polygons form a single connected component
|
439
|
-
visited = [False] * len(annotations)
|
440
|
-
stack = [0] # Start from the first polygon
|
441
|
-
visited[0] = True
|
442
|
-
visited_count = 1
|
443
|
-
|
444
|
-
while stack:
|
445
|
-
node = stack.pop()
|
446
|
-
for neighbor in overlap_graph[node]:
|
447
|
-
if not visited[neighbor]:
|
448
|
-
visited[neighbor] = True
|
449
|
-
visited_count += 1
|
450
|
-
stack.append(neighbor)
|
451
|
-
|
452
|
-
# If not all polygons are reachable, we have multiple disconnected components
|
453
|
-
if visited_count != len(annotations):
|
454
|
-
# Multiple disconnected components - return early doing nothing
|
455
|
-
return None
|
456
|
-
|
457
|
-
# All polygons form a single connected component - merge them
|
458
|
-
# Combine polygons by creating a binary mask of all polygons
|
459
|
-
# Determine the bounds of all polygons
|
460
|
-
min_x = min(anno.cropped_bbox[0] for anno in annotations)
|
461
|
-
min_y = min(anno.cropped_bbox[1] for anno in annotations)
|
462
|
-
max_x = max(anno.cropped_bbox[2] for anno in annotations)
|
463
|
-
max_y = max(anno.cropped_bbox[3] for anno in annotations)
|
464
|
-
|
465
|
-
# Add padding
|
466
|
-
padding = 20
|
467
|
-
min_x -= padding
|
468
|
-
min_y -= padding
|
469
|
-
max_x += padding
|
470
|
-
max_y += padding
|
471
|
-
|
472
|
-
# Create a mask for the combined shape
|
473
|
-
width = int(max_x - min_x)
|
474
|
-
height = int(max_y - min_y)
|
475
|
-
if width <= 0 or height <= 0:
|
476
|
-
width = max(1, width)
|
477
|
-
height = max(1, height)
|
478
|
-
|
479
|
-
combined_mask = np.zeros((height, width), dtype=np.uint8)
|
480
|
-
|
481
|
-
# Draw all polygons on the mask
|
482
|
-
for annotation in annotations:
|
483
|
-
polygon_points = np.array([(point.x() - min_x, point.y() - min_y) for point in annotation.points])
|
484
|
-
cv2.fillPoly(combined_mask, [polygon_points.astype(np.int32)], 255)
|
485
|
-
|
486
|
-
# Find contours of the combined shape
|
487
|
-
contours, _ = cv2.findContours(combined_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
488
|
-
|
489
|
-
if contours:
|
490
|
-
# Get the largest contour
|
491
|
-
largest_contour = max(contours, key=cv2.contourArea)
|
492
|
-
|
493
|
-
# Simplify the contour slightly to reduce point count
|
494
|
-
epsilon = 0.0005 * cv2.arcLength(largest_contour, True)
|
495
|
-
approx_contour = cv2.approxPolyDP(largest_contour, epsilon, True)
|
496
|
-
|
497
|
-
# Convert back to original coordinate system and to QPointF
|
498
|
-
hull_points = [QPointF(point[0][0] + min_x, point[0][1] + min_y) for point in approx_contour]
|
499
|
-
else:
|
500
|
-
# Fallback to all points if contour finding fails
|
501
|
-
all_points = []
|
502
|
-
for annotation in annotations:
|
503
|
-
all_points.extend(annotation.points)
|
504
|
-
hull_points = all_points
|
505
|
-
|
506
|
-
# Extract info from the first annotation
|
507
|
-
short_label_code = annotations[0].label.short_label_code
|
508
|
-
long_label_code = annotations[0].label.long_label_code
|
509
|
-
color = annotations[0].label.color
|
510
|
-
image_path = annotations[0].image_path
|
511
|
-
label_id = annotations[0].label.id
|
512
|
-
|
513
|
-
# Create a new annotation with the combined points
|
514
|
-
new_annotation = cls(
|
515
|
-
points=hull_points,
|
516
|
-
short_label_code=short_label_code,
|
517
|
-
long_label_code=long_label_code,
|
518
|
-
color=color,
|
519
|
-
image_path=image_path,
|
520
|
-
label_id=label_id
|
521
|
-
)
|
522
|
-
|
523
|
-
# All input annotations have the same rasterio source, use it for the new one
|
524
|
-
if all(hasattr(anno, 'rasterio_src') and anno.rasterio_src is not None for anno in annotations):
|
525
|
-
if len(set(id(anno.rasterio_src) for anno in annotations)) == 1:
|
526
|
-
new_annotation.rasterio_src = annotations[0].rasterio_src
|
527
|
-
new_annotation.create_cropped_image(new_annotation.rasterio_src)
|
534
|
+
try:
|
535
|
+
# 1. Convert all input annotations to Shapely Polygons, preserving their holes.
|
536
|
+
shapely_polygons = []
|
537
|
+
for anno in annotations:
|
538
|
+
shell = [(p.x(), p.y()) for p in anno.points]
|
539
|
+
holes = [[(p.x(), p.y()) for p in hole] for hole in getattr(anno, 'holes', [])]
|
540
|
+
shapely_polygons.append(Polygon(shell, holes))
|
541
|
+
|
542
|
+
# 2. Perform the union operation.
|
543
|
+
merged_geom = unary_union(shapely_polygons)
|
544
|
+
|
545
|
+
# --- Get properties from the first annotation to transfer to the new one ---
|
546
|
+
first_anno = annotations[0]
|
547
|
+
common_args = {
|
548
|
+
"short_label_code": first_anno.label.short_label_code,
|
549
|
+
"long_label_code": first_anno.label.long_label_code,
|
550
|
+
"color": first_anno.label.color,
|
551
|
+
"image_path": first_anno.image_path,
|
552
|
+
"label_id": first_anno.label.id
|
553
|
+
}
|
554
|
+
|
555
|
+
# 3. Check the result and build the appropriate new annotation.
|
556
|
+
if merged_geom.geom_type == 'Polygon':
|
557
|
+
# The result is a single polygon (potentially with new holes).
|
558
|
+
exterior_points = [QPointF(x, y) for x, y in merged_geom.exterior.coords]
|
559
|
+
interior_holes = [[QPointF(x, y) for x, y in interior.coords] for interior in merged_geom.interiors]
|
560
|
+
|
561
|
+
return cls(points=exterior_points, holes=interior_holes, **common_args)
|
562
|
+
|
563
|
+
elif merged_geom.geom_type == 'MultiPolygon':
|
564
|
+
# The result is multiple, disjoint polygons. Create a MultiPolygonAnnotation.
|
565
|
+
new_polygons = []
|
566
|
+
for poly in merged_geom.geoms:
|
567
|
+
exterior_points = [QPointF(x, y) for x, y in poly.exterior.coords]
|
568
|
+
interior_holes = [[QPointF(x, y) for x, y in interior.coords] for interior in poly.interiors]
|
569
|
+
new_polygons.append(cls(points=exterior_points, holes=interior_holes, **common_args))
|
570
|
+
|
571
|
+
return MultiPolygonAnnotation(polygons=new_polygons, **common_args)
|
572
|
+
|
573
|
+
else:
|
574
|
+
# The geometry is empty or an unexpected type.
|
575
|
+
return None
|
528
576
|
|
529
|
-
|
577
|
+
except Exception as e:
|
578
|
+
print(f"Error during polygon combination: {e}")
|
579
|
+
return None
|
530
580
|
|
531
581
|
@classmethod
|
532
582
|
def cut(cls, annotation, cutting_points: list):
|
533
|
-
"""
|
534
|
-
|
535
|
-
|
536
|
-
annotation: A PolygonAnnotation object.
|
537
|
-
cutting_points: List of QPointF objects defining a cutting line.
|
538
|
-
|
539
|
-
Returns:
|
540
|
-
List of new PolygonAnnotation objects resulting from the cut.
|
583
|
+
"""
|
584
|
+
Cut a polygon annotation where it intersects with a cutting line.
|
585
|
+
This now correctly handles cutting polygons that contain holes.
|
541
586
|
"""
|
542
587
|
if not annotation or not cutting_points or len(cutting_points) < 2:
|
543
588
|
return [annotation] if annotation else []
|
544
589
|
|
545
|
-
#
|
546
|
-
|
547
|
-
|
548
|
-
|
549
|
-
|
550
|
-
|
551
|
-
|
590
|
+
# 1. Create a Shapely Polygon, including its shell and any holes.
|
591
|
+
try:
|
592
|
+
shell_coords = [(p.x(), p.y()) for p in annotation.points]
|
593
|
+
if len(shell_coords) < 3: # Not a valid polygon to cut
|
594
|
+
return [annotation]
|
595
|
+
holes_coords = [[(p.x(), p.y()) for p in hole] for hole in getattr(annotation, 'holes', [])]
|
596
|
+
polygon = Polygon(shell_coords, holes_coords)
|
597
|
+
|
598
|
+
except Exception:
|
599
|
+
# Invalid geometry to begin with
|
600
|
+
return [annotation]
|
552
601
|
|
553
|
-
# Create cutting line
|
602
|
+
# Create the cutting line
|
554
603
|
line_points = [(point.x(), point.y()) for point in cutting_points]
|
555
604
|
cutting_line = LineString(line_points)
|
556
605
|
|
@@ -559,30 +608,29 @@ class PolygonAnnotation(Annotation):
|
|
559
608
|
return [annotation] # No intersection, return original
|
560
609
|
|
561
610
|
try:
|
562
|
-
# Split the polygon
|
563
|
-
|
611
|
+
# 2. Split the polygon; Shapely handles the holes automatically.
|
612
|
+
split_geometries = split(polygon, cutting_line)
|
564
613
|
|
565
|
-
# Convert the split geometries back to polygons
|
566
614
|
result_annotations = []
|
567
|
-
min_area = 10 # Minimum area threshold
|
615
|
+
min_area = 10 # Minimum area threshold to avoid tiny fragments
|
568
616
|
|
569
|
-
|
570
|
-
|
617
|
+
# 3. Reconstruct new PolygonAnnotations from the resulting geometries.
|
618
|
+
for geom in split_geometries.geoms:
|
571
619
|
if geom.area < min_area or not isinstance(geom, Polygon):
|
572
620
|
continue
|
573
621
|
|
574
|
-
#
|
575
|
-
|
622
|
+
# Extract the exterior coordinates
|
623
|
+
new_points = [QPointF(x, y) for x, y in geom.exterior.coords]
|
624
|
+
# Also extract the coordinates for any new holes
|
625
|
+
new_holes = [[QPointF(x, y) for x, y in interior.coords] for interior in geom.interiors]
|
576
626
|
|
577
|
-
|
578
|
-
new_points = [QPointF(x, y) for x, y in coords[:-1]]
|
579
|
-
|
580
|
-
if len(new_points) < 3: # Skip if we don't have enough points for a polygon
|
627
|
+
if len(new_points) < 3:
|
581
628
|
continue
|
582
|
-
|
583
|
-
# Create a new
|
629
|
+
|
630
|
+
# Create a new annotation with the new points and holes
|
584
631
|
new_anno = cls(
|
585
632
|
points=new_points,
|
633
|
+
holes=new_holes, # Pass the new holes
|
586
634
|
short_label_code=annotation.label.short_label_code,
|
587
635
|
long_label_code=annotation.label.long_label_code,
|
588
636
|
color=annotation.label.color,
|
@@ -597,45 +645,141 @@ class PolygonAnnotation(Annotation):
|
|
597
645
|
|
598
646
|
result_annotations.append(new_anno)
|
599
647
|
|
600
|
-
# If no valid polygons were created, return the original
|
601
648
|
return result_annotations if result_annotations else [annotation]
|
602
649
|
|
603
650
|
except Exception as e:
|
604
|
-
# Log the error and return the original polygon
|
605
651
|
print(f"Error during polygon cutting: {e}")
|
606
652
|
return [annotation]
|
653
|
+
|
654
|
+
@classmethod
|
655
|
+
def subtract(cls, base_annotation, cutter_annotations: list):
|
656
|
+
"""
|
657
|
+
Performs a symmetrical subtraction.
|
658
|
+
|
659
|
+
Subtracts the combined area of cutter_annotations from the base_annotation,
|
660
|
+
and also subtracts the base_annotation from each of the cutter_annotations.
|
661
|
+
Returns a list of all resulting annotation fragments.
|
662
|
+
"""
|
663
|
+
from shapely.geometry import Polygon
|
664
|
+
from shapely.ops import unary_union
|
665
|
+
from coralnet_toolbox.Annotations.QtMultiPolygonAnnotation import MultiPolygonAnnotation
|
666
|
+
|
667
|
+
def _create_annotations_from_geom(geom, source_annotation):
|
668
|
+
"""Creates appropriate Annotation objects from a Shapely geometry."""
|
669
|
+
if geom.is_empty:
|
670
|
+
return []
|
671
|
+
|
672
|
+
common_args = {
|
673
|
+
"short_label_code": source_annotation.label.short_label_code,
|
674
|
+
"long_label_code": source_annotation.label.long_label_code,
|
675
|
+
"color": source_annotation.label.color,
|
676
|
+
"image_path": source_annotation.image_path,
|
677
|
+
"label_id": source_annotation.label.id
|
678
|
+
}
|
679
|
+
|
680
|
+
if geom.geom_type == 'Polygon':
|
681
|
+
exterior_points = [QPointF(x, y) for x, y in geom.exterior.coords]
|
682
|
+
interior_holes = [[QPointF(x, y) for x, y in interior.coords] for interior in geom.interiors]
|
683
|
+
return [cls(points=exterior_points, holes=interior_holes, **common_args)]
|
684
|
+
|
685
|
+
elif geom.geom_type == 'MultiPolygon':
|
686
|
+
new_polygons = []
|
687
|
+
for poly in geom.geoms:
|
688
|
+
if poly.is_empty: continue
|
689
|
+
exterior_points = [QPointF(x, y) for x, y in poly.exterior.coords]
|
690
|
+
interior_holes = [[QPointF(x, y) for x, y in interior.coords] for interior in poly.interiors]
|
691
|
+
new_polygons.append(cls(points=exterior_points, holes=interior_holes, **common_args))
|
692
|
+
|
693
|
+
if new_polygons:
|
694
|
+
return [MultiPolygonAnnotation(polygons=new_polygons, **common_args)]
|
695
|
+
|
696
|
+
return []
|
697
|
+
|
698
|
+
if not base_annotation or not cutter_annotations:
|
699
|
+
return []
|
607
700
|
|
701
|
+
try:
|
702
|
+
# --- Convert all annotations to Shapely objects ---
|
703
|
+
base_shell = [(p.x(), p.y()) for p in base_annotation.points]
|
704
|
+
base_holes = [[(p.x(), p.y()) for p in hole] for hole in getattr(base_annotation, 'holes', [])]
|
705
|
+
base_polygon = Polygon(base_shell, base_holes)
|
706
|
+
|
707
|
+
cutter_polygons, cutter_source_annotations = [], []
|
708
|
+
for anno in cutter_annotations:
|
709
|
+
if isinstance(anno, MultiPolygonAnnotation):
|
710
|
+
for poly in anno.polygons:
|
711
|
+
shell = [(p.x(), p.y()) for p in poly.points]
|
712
|
+
holes = [[(p.x(), p.y()) for p in hole] for hole in getattr(poly, 'holes', [])]
|
713
|
+
cutter_polygons.append(Polygon(shell, holes))
|
714
|
+
cutter_source_annotations.append(poly)
|
715
|
+
else:
|
716
|
+
shell = [(p.x(), p.y()) for p in anno.points]
|
717
|
+
holes = [[(p.x(), p.y()) for p in hole] for hole in getattr(anno, 'holes', [])]
|
718
|
+
cutter_polygons.append(Polygon(shell, holes))
|
719
|
+
cutter_source_annotations.append(anno)
|
720
|
+
|
721
|
+
cutter_union = unary_union(cutter_polygons)
|
722
|
+
|
723
|
+
if not base_polygon.intersects(cutter_union):
|
724
|
+
return [] # No overlap, so return an empty list to signal no-op
|
725
|
+
|
726
|
+
all_results = []
|
727
|
+
|
728
|
+
# --- 1. Calculate Base - CutterUnion ---
|
729
|
+
result_base_geom = base_polygon.difference(cutter_union)
|
730
|
+
all_results.extend(_create_annotations_from_geom(result_base_geom, base_annotation))
|
731
|
+
|
732
|
+
# --- 2. Calculate each Cutter - Base ---
|
733
|
+
for i, cutter_poly in enumerate(cutter_polygons):
|
734
|
+
source_anno = cutter_source_annotations[i]
|
735
|
+
result_cutter_geom = cutter_poly.difference(base_polygon)
|
736
|
+
all_results.extend(_create_annotations_from_geom(result_cutter_geom, source_anno))
|
737
|
+
|
738
|
+
return all_results
|
739
|
+
|
740
|
+
except Exception as e:
|
741
|
+
print(f"Error during polygon subtraction: {e}")
|
742
|
+
return []
|
743
|
+
|
608
744
|
def to_dict(self):
|
609
|
-
"""Convert the annotation to a dictionary
|
745
|
+
"""Convert the annotation to a dictionary, including points and holes."""
|
610
746
|
base_dict = super().to_dict()
|
611
747
|
base_dict.update({
|
612
|
-
'points': [(point.x(), point.y()) for point in self.points]
|
748
|
+
'points': [(point.x(), point.y()) for point in self.points],
|
749
|
+
'holes': [[(p.x(), p.y()) for p in hole] for hole in self.holes]
|
613
750
|
})
|
614
751
|
return base_dict
|
615
752
|
|
616
753
|
@classmethod
|
617
754
|
def from_dict(cls, data, label_window):
|
618
|
-
"""Create a PolygonAnnotation object from a dictionary
|
755
|
+
"""Create a PolygonAnnotation object from a dictionary, including holes."""
|
619
756
|
points = [QPointF(x, y) for x, y in data['points']]
|
620
|
-
|
621
|
-
|
622
|
-
|
623
|
-
|
624
|
-
|
625
|
-
|
757
|
+
|
758
|
+
# Check for and process hole data if it exists.
|
759
|
+
holes_data = data.get('holes', [])
|
760
|
+
holes = [[QPointF(x, y) for x, y in hole_data] for hole_data in holes_data]
|
761
|
+
|
762
|
+
# Pass the points and holes to the constructor.
|
763
|
+
annotation = cls(
|
764
|
+
points=points,
|
765
|
+
holes=holes,
|
766
|
+
short_label_code=data['label_short_code'],
|
767
|
+
long_label_code=data['label_long_code'],
|
768
|
+
color=QColor(*data['annotation_color']),
|
769
|
+
image_path=data['image_path'],
|
770
|
+
label_id=data['label_id']
|
771
|
+
)
|
626
772
|
annotation.data = data.get('data', {})
|
627
773
|
|
628
|
-
#
|
774
|
+
# --- Remainder of the method is for handling confidence scores ---
|
629
775
|
machine_confidence = {}
|
630
776
|
for short_label_code, confidence in data.get('machine_confidence', {}).items():
|
631
777
|
label = label_window.get_label_by_short_code(short_label_code)
|
632
778
|
if label:
|
633
779
|
machine_confidence[label] = confidence
|
634
780
|
|
635
|
-
# Set the machine confidence
|
636
781
|
annotation.update_machine_confidence(machine_confidence, from_import=True)
|
637
782
|
|
638
|
-
# Override the verified attribute if it exists in the data
|
639
783
|
if 'verified' in data:
|
640
784
|
annotation.set_verified(data['verified'])
|
641
785
|
|