coralnet-toolbox 0.0.72__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/AutoDistill/QtDeployModel.py +23 -12
- coralnet_toolbox/CoralNet/QtDownload.py +2 -1
- coralnet_toolbox/Explorer/QtDataItem.py +1 -1
- coralnet_toolbox/Explorer/QtExplorer.py +159 -17
- coralnet_toolbox/Explorer/QtSettingsWidgets.py +160 -86
- 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/DeployModel/QtDetect.py +22 -11
- coralnet_toolbox/MachineLearning/DeployModel/QtSegment.py +22 -10
- coralnet_toolbox/MachineLearning/ExportDataset/QtBase.py +61 -24
- coralnet_toolbox/MachineLearning/ExportDataset/QtClassify.py +5 -1
- coralnet_toolbox/MachineLearning/ExportDataset/QtDetect.py +19 -6
- coralnet_toolbox/MachineLearning/ExportDataset/QtSegment.py +21 -8
- coralnet_toolbox/MachineLearning/ImportDataset/QtBase.py +42 -22
- coralnet_toolbox/MachineLearning/VideoInference/QtBase.py +0 -4
- coralnet_toolbox/QtAnnotationWindow.py +42 -14
- coralnet_toolbox/QtEventFilter.py +19 -2
- coralnet_toolbox/QtImageWindow.py +134 -86
- coralnet_toolbox/QtLabelWindow.py +14 -2
- coralnet_toolbox/QtMainWindow.py +122 -9
- coralnet_toolbox/QtProgressBar.py +52 -27
- coralnet_toolbox/Rasters/QtRaster.py +59 -7
- coralnet_toolbox/Rasters/RasterTableModel.py +42 -14
- coralnet_toolbox/SAM/QtBatchInference.py +0 -2
- coralnet_toolbox/SAM/QtDeployGenerator.py +22 -11
- coralnet_toolbox/SAM/QtDeployPredictor.py +10 -0
- coralnet_toolbox/SeeAnything/QtBatchInference.py +19 -221
- coralnet_toolbox/SeeAnything/QtDeployGenerator.py +1634 -0
- coralnet_toolbox/SeeAnything/QtDeployPredictor.py +107 -154
- coralnet_toolbox/SeeAnything/QtTrainModel.py +115 -45
- coralnet_toolbox/SeeAnything/__init__.py +2 -0
- coralnet_toolbox/Tools/QtCutSubTool.py +18 -2
- coralnet_toolbox/Tools/QtResizeSubTool.py +19 -2
- coralnet_toolbox/Tools/QtSAMTool.py +222 -57
- coralnet_toolbox/Tools/QtSeeAnythingTool.py +223 -55
- coralnet_toolbox/Tools/QtSelectSubTool.py +6 -4
- coralnet_toolbox/Tools/QtSelectTool.py +27 -3
- coralnet_toolbox/Tools/QtSubtractSubTool.py +66 -0
- coralnet_toolbox/Tools/QtWorkAreaTool.py +25 -13
- 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.72.dist-info → coralnet_toolbox-0.0.74.dist-info}/RECORD +56 -53
- coralnet_toolbox-0.0.72.dist-info/METADATA +0 -341
- {coralnet_toolbox-0.0.72.dist-info → coralnet_toolbox-0.0.74.dist-info}/WHEEL +0 -0
- {coralnet_toolbox-0.0.72.dist-info → coralnet_toolbox-0.0.74.dist-info}/entry_points.txt +0 -0
- {coralnet_toolbox-0.0.72.dist-info → coralnet_toolbox-0.0.74.dist-info}/licenses/LICENSE.txt +0 -0
- {coralnet_toolbox-0.0.72.dist-info → coralnet_toolbox-0.0.74.dist-info}/top_level.txt +0 -0
@@ -1,16 +1,18 @@
|
|
1
1
|
import warnings
|
2
2
|
|
3
|
-
import cv2
|
4
|
-
import numpy as np
|
5
3
|
from rasterio.windows import Window
|
6
4
|
|
5
|
+
from shapely.ops import unary_union
|
6
|
+
from shapely.geometry import Point, Polygon
|
7
|
+
|
7
8
|
from PyQt5.QtCore import Qt, QPointF, QRectF
|
8
|
-
from PyQt5.QtWidgets import QGraphicsScene,
|
9
|
+
from PyQt5.QtWidgets import QGraphicsScene, QGraphicsPathItem
|
9
10
|
from PyQt5.QtGui import (QPixmap, QColor, QPen, QBrush, QPainter,
|
10
|
-
QPolygonF, QImage, QRegion)
|
11
|
+
QPolygonF, QImage, QRegion, QPainterPath)
|
11
12
|
|
12
13
|
from coralnet_toolbox.Annotations.QtAnnotation import Annotation
|
13
14
|
from coralnet_toolbox.Annotations.QtPolygonAnnotation import PolygonAnnotation
|
15
|
+
from coralnet_toolbox.Annotations.QtMultiPolygonAnnotation import MultiPolygonAnnotation
|
14
16
|
|
15
17
|
from coralnet_toolbox.utilities import rasterio_to_cropped_image
|
16
18
|
|
@@ -63,25 +65,77 @@ class PatchAnnotation(Annotation):
|
|
63
65
|
self.cropped_bbox = (min_x, min_y, max_x, max_y)
|
64
66
|
|
65
67
|
def contains_point(self, point: QPointF):
|
66
|
-
"""
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
68
|
+
"""
|
69
|
+
Check if the given point is inside the polygon using Shapely.
|
70
|
+
"""
|
71
|
+
try:
|
72
|
+
# Convert the patch's corners to coordinate tuples for Shapely
|
73
|
+
qt_polygon = self.get_polygon()
|
74
|
+
shell_coords = [(p.x(), p.y()) for p in qt_polygon]
|
75
|
+
|
76
|
+
# Create a Shapely polygon
|
77
|
+
shapely_polygon = Polygon(shell=shell_coords)
|
78
|
+
|
79
|
+
# Convert the input QPointF to a Shapely Point
|
80
|
+
shapely_point = Point(point.x(), point.y())
|
81
|
+
|
82
|
+
# Return Shapely's boolean result for the containment check
|
83
|
+
return shapely_polygon.contains(shapely_point)
|
84
|
+
|
85
|
+
except Exception:
|
86
|
+
# Fallback to the original QRectF implementation if Shapely fails
|
87
|
+
half_size = self.annotation_size / 2
|
88
|
+
rect = QRectF(self.center_xy.x() - half_size,
|
89
|
+
self.center_xy.y() - half_size,
|
90
|
+
self.annotation_size,
|
91
|
+
self.annotation_size)
|
92
|
+
return rect.contains(point)
|
73
93
|
|
74
94
|
def get_centroid(self):
|
75
95
|
"""Get the centroid of the annotation."""
|
76
96
|
return (float(self.center_xy.x()), float(self.center_xy.y()))
|
77
97
|
|
78
98
|
def get_area(self):
|
79
|
-
"""
|
80
|
-
|
99
|
+
"""
|
100
|
+
Calculate the net area of the polygon using Shapely.
|
101
|
+
"""
|
102
|
+
try:
|
103
|
+
# Convert the patch's corners to coordinate tuples for Shapely
|
104
|
+
qt_polygon = self.get_polygon()
|
105
|
+
shell_coords = [(p.x(), p.y()) for p in qt_polygon]
|
106
|
+
|
107
|
+
# A valid polygon needs at least 3 points
|
108
|
+
if len(shell_coords) < 3:
|
109
|
+
return 0.0
|
110
|
+
|
111
|
+
# Create a Shapely polygon and return its area
|
112
|
+
shapely_polygon = Polygon(shell=shell_coords)
|
113
|
+
return shapely_polygon.area
|
114
|
+
|
115
|
+
except Exception:
|
116
|
+
# Fallback to the original implementation if Shapely fails
|
117
|
+
return self.annotation_size * self.annotation_size
|
81
118
|
|
82
119
|
def get_perimeter(self):
|
83
|
-
"""
|
84
|
-
|
120
|
+
"""
|
121
|
+
Calculate the perimeter of the polygon using Shapely.
|
122
|
+
"""
|
123
|
+
try:
|
124
|
+
# Convert the patch's corners to coordinate tuples for Shapely
|
125
|
+
qt_polygon = self.get_polygon()
|
126
|
+
shell_coords = [(p.x(), p.y()) for p in qt_polygon]
|
127
|
+
|
128
|
+
# A shape with fewer than 2 points has no length
|
129
|
+
if len(shell_coords) < 2:
|
130
|
+
return 0.0
|
131
|
+
|
132
|
+
# Create a Shapely polygon and return its perimeter (length)
|
133
|
+
shapely_polygon = Polygon(shell=shell_coords)
|
134
|
+
return shapely_polygon.length
|
135
|
+
|
136
|
+
except Exception:
|
137
|
+
# Fallback to the original implementation if Shapely fails
|
138
|
+
return 4 * self.annotation_size
|
85
139
|
|
86
140
|
def get_polygon(self):
|
87
141
|
"""Get the polygon representation of this patch (a square)."""
|
@@ -93,6 +147,20 @@ class PatchAnnotation(Annotation):
|
|
93
147
|
QPointF(self.center_xy.x() - half_size, self.center_xy.y() + half_size), # Bottom-left
|
94
148
|
]
|
95
149
|
return QPolygonF(points)
|
150
|
+
|
151
|
+
def get_painter_path(self) -> QPainterPath:
|
152
|
+
"""
|
153
|
+
Get a QPainterPath representation of the annotation.
|
154
|
+
"""
|
155
|
+
path = QPainterPath()
|
156
|
+
|
157
|
+
# Get the square's corners from the existing get_polygon method
|
158
|
+
polygon = self.get_polygon()
|
159
|
+
|
160
|
+
# Add the polygon to the path
|
161
|
+
path.addPolygon(polygon)
|
162
|
+
|
163
|
+
return path
|
96
164
|
|
97
165
|
def get_bounding_box_top_left(self):
|
98
166
|
"""Get the top-left corner of the bounding box."""
|
@@ -105,65 +173,52 @@ class PatchAnnotation(Annotation):
|
|
105
173
|
return QPointF(self.center_xy.x() + half_size, self.center_xy.y() + half_size)
|
106
174
|
|
107
175
|
def get_cropped_image_graphic(self):
|
108
|
-
"""Get the cropped image with a
|
176
|
+
"""Get the cropped image with a solid outline."""
|
109
177
|
if self.cropped_image is None:
|
110
178
|
return None
|
111
179
|
|
112
180
|
# Create a QImage with transparent background for the mask
|
113
181
|
masked_image = QImage(self.cropped_image.size(), QImage.Format_ARGB32)
|
114
|
-
masked_image.fill(Qt.transparent)
|
115
|
-
|
116
|
-
# Create a QPainter to draw the polygon onto the mask
|
117
|
-
painter = QPainter(masked_image)
|
118
|
-
painter.setRenderHint(QPainter.Antialiasing)
|
119
|
-
painter.setBrush(QBrush(Qt.white)) # White fill for the mask area
|
120
|
-
painter.setPen(Qt.NoPen)
|
121
|
-
|
122
|
-
# Define the square's corners as a polygon
|
123
|
-
cropped_points = [
|
124
|
-
QPointF(0, 0),
|
125
|
-
QPointF(self.cropped_image.width(), 0),
|
126
|
-
QPointF(self.cropped_image.width(), self.cropped_image.height()),
|
127
|
-
QPointF(0, self.cropped_image.height())
|
128
|
-
]
|
182
|
+
masked_image.fill(Qt.transparent)
|
129
183
|
|
130
|
-
# Create a
|
131
|
-
|
184
|
+
# Create a painter to draw the path onto the mask
|
185
|
+
mask_painter = QPainter(masked_image)
|
186
|
+
mask_painter.setRenderHint(QPainter.Antialiasing)
|
187
|
+
mask_painter.setBrush(QBrush(Qt.white)) # White fill for the mask area
|
188
|
+
mask_painter.setPen(Qt.NoPen)
|
189
|
+
|
190
|
+
# Define the square's corners relative to the cropped image (0,0)
|
191
|
+
path = QPainterPath()
|
192
|
+
path.addRect(0, 0, self.cropped_image.width(), self.cropped_image.height())
|
132
193
|
|
133
|
-
# Draw the
|
134
|
-
|
135
|
-
|
194
|
+
# Draw the path onto the mask
|
195
|
+
mask_painter.drawPath(path)
|
196
|
+
mask_painter.end()
|
136
197
|
|
137
|
-
# Convert the mask
|
138
|
-
# We want the inside of the polygon to show the image, so we DON'T use MaskInColor
|
198
|
+
# Convert the mask to a QRegion for clipping
|
139
199
|
mask_pixmap = QPixmap.fromImage(masked_image)
|
140
200
|
mask_bitmap = mask_pixmap.createMaskFromColor(Qt.white, Qt.MaskOutColor)
|
141
|
-
|
142
|
-
# Convert bitmap to region for clipping
|
143
201
|
mask_region = QRegion(mask_bitmap)
|
144
202
|
|
145
|
-
# Create the result image
|
203
|
+
# Create the result image with the background at 50% opacity
|
146
204
|
cropped_image_graphic = QPixmap(self.cropped_image.size())
|
147
|
-
|
148
|
-
# First draw the entire original image at 50% opacity (for area outside polygon)
|
149
205
|
result_painter = QPainter(cropped_image_graphic)
|
150
206
|
result_painter.setRenderHint(QPainter.Antialiasing)
|
151
|
-
result_painter.setOpacity(0.5)
|
207
|
+
result_painter.setOpacity(0.5)
|
152
208
|
result_painter.drawPixmap(0, 0, self.cropped_image)
|
153
209
|
|
154
|
-
#
|
155
|
-
result_painter.setOpacity(1.0)
|
210
|
+
# Draw the full-opacity image inside the masked region
|
211
|
+
result_painter.setOpacity(1.0)
|
156
212
|
result_painter.setClipRegion(mask_region)
|
157
213
|
result_painter.drawPixmap(0, 0, self.cropped_image)
|
158
214
|
|
159
|
-
# Draw the
|
215
|
+
# Draw the solid line outline on top
|
160
216
|
pen = QPen(Qt.black)
|
161
|
-
pen.setStyle(Qt.SolidLine)
|
162
|
-
pen.setWidth(1)
|
217
|
+
pen.setStyle(Qt.SolidLine)
|
218
|
+
pen.setWidth(1)
|
163
219
|
result_painter.setPen(pen)
|
164
220
|
result_painter.setClipping(False) # Disable clipping for the outline
|
165
|
-
result_painter.
|
166
|
-
|
221
|
+
result_painter.drawPath(path)
|
167
222
|
result_painter.end()
|
168
223
|
|
169
224
|
return cropped_image_graphic
|
@@ -195,31 +250,25 @@ class PatchAnnotation(Annotation):
|
|
195
250
|
self.annotationUpdated.emit(self) # Notify update
|
196
251
|
|
197
252
|
def create_graphics_item(self, scene: QGraphicsScene):
|
198
|
-
"""Create all graphics items for the
|
199
|
-
#
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
]
|
207
|
-
self.graphics_item = QGraphicsPolygonItem(QPolygonF(points))
|
208
|
-
# Call parent to handle group and helpers
|
253
|
+
"""Create all graphics items for the annotation and add them to the scene."""
|
254
|
+
# Get the complete shape as a QPainterPath.
|
255
|
+
path = self.get_painter_path()
|
256
|
+
|
257
|
+
# Use a QGraphicsPathItem for rendering.
|
258
|
+
self.graphics_item = QGraphicsPathItem(path)
|
259
|
+
|
260
|
+
# Call the parent class method to handle grouping, styling, and adding to the scene.
|
209
261
|
super().create_graphics_item(scene)
|
210
262
|
|
211
263
|
def update_graphics_item(self):
|
212
264
|
"""Update the graphical representation of the patch annotation."""
|
213
|
-
#
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
]
|
221
|
-
self.graphics_item = QGraphicsPolygonItem(QPolygonF(points))
|
222
|
-
# Call parent to handle group and helpers
|
265
|
+
# Get the complete shape as a QPainterPath.
|
266
|
+
path = self.get_painter_path()
|
267
|
+
|
268
|
+
# Use a QGraphicsPathItem to correctly represent the shape.
|
269
|
+
self.graphics_item = QGraphicsPathItem(path)
|
270
|
+
|
271
|
+
# Call the parent class method to handle rebuilding the graphics group.
|
223
272
|
super().update_graphics_item()
|
224
273
|
|
225
274
|
def update_polygon(self, delta):
|
@@ -259,153 +308,53 @@ class PatchAnnotation(Annotation):
|
|
259
308
|
@classmethod
|
260
309
|
def combine(cls, annotations: list):
|
261
310
|
"""
|
262
|
-
Combine
|
263
|
-
|
311
|
+
Combine annotations using Shapely's union operation.
|
312
|
+
Returns a single PolygonAnnotation or a MultiPolygonAnnotation.
|
264
313
|
"""
|
265
314
|
if not annotations:
|
266
315
|
return None
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
patch.get_bounding_box_top_left(),
|
310
|
-
patch.get_bounding_box_bottom_right()
|
311
|
-
)
|
312
|
-
poly_tl = poly.get_bounding_box_top_left()
|
313
|
-
poly_br = poly.get_bounding_box_bottom_right()
|
314
|
-
if any(rect_patch.contains(pt) for pt in [poly_tl, poly_br]):
|
315
|
-
has_touch = True
|
316
|
-
break
|
317
|
-
if not has_touch:
|
318
|
-
return None # Cancel combine if any annotation is not touching another
|
319
|
-
|
320
|
-
# Separate patches and polygons
|
321
|
-
result_polygons = []
|
322
|
-
|
323
|
-
# If we have patches, combine them into a polygon
|
324
|
-
if patches:
|
325
|
-
# Determine the bounds for creating a combined mask
|
326
|
-
min_x = min(anno.get_bounding_box_top_left().x() for anno in patches)
|
327
|
-
min_y = min(anno.get_bounding_box_top_left().y() for anno in patches)
|
328
|
-
max_x = max(anno.get_bounding_box_bottom_right().x() for anno in patches)
|
329
|
-
max_y = max(anno.get_bounding_box_bottom_right().y() for anno in patches)
|
330
|
-
|
331
|
-
# Add padding for safety
|
332
|
-
padding = 20
|
333
|
-
min_x -= padding
|
334
|
-
min_y -= padding
|
335
|
-
max_x += padding
|
336
|
-
max_y += padding
|
337
|
-
|
338
|
-
# Create a mask for the combined shape
|
339
|
-
width = int(max_x - min_x)
|
340
|
-
height = int(max_y - min_y)
|
341
|
-
if width <= 0 or height <= 0:
|
342
|
-
width = max(1, width)
|
343
|
-
height = max(1, height)
|
344
|
-
|
345
|
-
combined_mask = np.zeros((height, width), dtype=np.uint8)
|
346
|
-
|
347
|
-
# Draw all patches on the mask
|
348
|
-
for annotation in patches:
|
349
|
-
half_size = annotation.annotation_size / 2
|
350
|
-
rect_x = int(annotation.center_xy.x() - half_size - min_x)
|
351
|
-
rect_y = int(annotation.center_xy.y() - half_size - min_y)
|
352
|
-
rect_width = int(annotation.annotation_size)
|
353
|
-
rect_height = int(annotation.annotation_size)
|
354
|
-
|
355
|
-
# Make sure the rectangle is within the mask bounds
|
356
|
-
rect_x = max(0, rect_x)
|
357
|
-
rect_y = max(0, rect_y)
|
358
|
-
rect_width = min(width - rect_x, rect_width)
|
359
|
-
rect_height = min(height - rect_y, rect_height)
|
360
|
-
|
361
|
-
# Draw the rectangle on the mask
|
362
|
-
if rect_width > 0 and rect_height > 0:
|
363
|
-
combined_mask[rect_y: rect_y + rect_height, rect_x: rect_x + rect_width] = 255
|
364
|
-
|
365
|
-
# Find contours of the combined shape
|
366
|
-
contours, _ = cv2.findContours(combined_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
367
|
-
|
368
|
-
if contours:
|
369
|
-
# Get the largest contour
|
370
|
-
largest_contour = max(contours, key=cv2.contourArea)
|
371
|
-
|
372
|
-
# Simplify the contour slightly to reduce point count
|
373
|
-
epsilon = 0.0005 * cv2.arcLength(largest_contour, True)
|
374
|
-
approx_contour = cv2.approxPolyDP(largest_contour, epsilon, True)
|
375
|
-
|
376
|
-
# Convert back to original coordinate system and to QPointF
|
377
|
-
points = [QPointF(point[0][0] + min_x, point[0][1] + min_y) for point in approx_contour]
|
378
|
-
|
379
|
-
# Create a new polygon annotation
|
380
|
-
patches_polygon = PolygonAnnotation(
|
381
|
-
points=points,
|
382
|
-
short_label_code=first_annotation.label.short_label_code,
|
383
|
-
long_label_code=first_annotation.label.long_label_code,
|
384
|
-
color=first_annotation.label.color,
|
385
|
-
image_path=first_annotation.image_path,
|
386
|
-
label_id=first_annotation.label.id
|
387
|
-
)
|
388
|
-
|
389
|
-
# Copy rasterio source if available
|
390
|
-
if hasattr(first_annotation, 'rasterio_src') and first_annotation.rasterio_src is not None:
|
391
|
-
patches_polygon.rasterio_src = first_annotation.rasterio_src
|
392
|
-
patches_polygon.create_cropped_image(patches_polygon.rasterio_src)
|
393
|
-
|
394
|
-
result_polygons.append(patches_polygon)
|
395
|
-
|
396
|
-
# Add existing polygons to the result list
|
397
|
-
result_polygons.extend(polygons)
|
398
|
-
|
399
|
-
# If we only have one result polygon, return it
|
400
|
-
if len(result_polygons) == 1:
|
401
|
-
return result_polygons[0]
|
402
|
-
|
403
|
-
# If we have multiple polygons, combine them using PolygonAnnotation.combine
|
404
|
-
elif len(result_polygons) > 1:
|
405
|
-
return PolygonAnnotation.combine(result_polygons)
|
406
|
-
|
407
|
-
# Otherwise return None
|
408
|
-
return None
|
316
|
+
if len(annotations) == 1:
|
317
|
+
return annotations[0]
|
318
|
+
|
319
|
+
try:
|
320
|
+
# 1. Convert all input annotations to Shapely Polygons.
|
321
|
+
shapely_polygons = []
|
322
|
+
for anno in annotations:
|
323
|
+
# get_polygon() works for both PatchAnnotation and PolygonAnnotation
|
324
|
+
qt_polygon = anno.get_polygon()
|
325
|
+
points = [(p.x(), p.y()) for p in qt_polygon]
|
326
|
+
shapely_polygons.append(Polygon(points))
|
327
|
+
|
328
|
+
# 2. Perform the union operation.
|
329
|
+
merged_geom = unary_union(shapely_polygons)
|
330
|
+
|
331
|
+
# --- Get properties from the first annotation for the new one ---
|
332
|
+
first_anno = annotations[0]
|
333
|
+
common_args = {
|
334
|
+
"short_label_code": first_anno.label.short_label_code,
|
335
|
+
"long_label_code": first_anno.label.long_label_code,
|
336
|
+
"color": first_anno.label.color,
|
337
|
+
"image_path": first_anno.image_path,
|
338
|
+
"label_id": first_anno.label.id
|
339
|
+
}
|
340
|
+
|
341
|
+
# 3. Build the appropriate new annotation based on the result.
|
342
|
+
if merged_geom.geom_type == 'Polygon':
|
343
|
+
exterior_points = [QPointF(x, y) for x, y in merged_geom.exterior.coords]
|
344
|
+
return PolygonAnnotation(points=exterior_points, **common_args)
|
345
|
+
|
346
|
+
elif merged_geom.geom_type == 'MultiPolygon':
|
347
|
+
new_polygons = []
|
348
|
+
for poly in merged_geom.geoms:
|
349
|
+
exterior_points = [QPointF(x, y) for x, y in poly.exterior.coords]
|
350
|
+
new_polygons.append(PolygonAnnotation(points=exterior_points, **common_args))
|
351
|
+
return MultiPolygonAnnotation(polygons=new_polygons, **common_args)
|
352
|
+
|
353
|
+
return None # The geometry is empty or an unexpected type
|
354
|
+
|
355
|
+
except Exception as e:
|
356
|
+
print(f"Error during polygon combination: {e}")
|
357
|
+
return None
|
409
358
|
|
410
359
|
@classmethod
|
411
360
|
def cut(cls, annotations: list, cutting_points: list):
|