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.
Files changed (57) hide show
  1. coralnet_toolbox/Annotations/QtAnnotation.py +28 -69
  2. coralnet_toolbox/Annotations/QtMaskAnnotation.py +408 -0
  3. coralnet_toolbox/Annotations/QtMultiPolygonAnnotation.py +72 -56
  4. coralnet_toolbox/Annotations/QtPatchAnnotation.py +165 -216
  5. coralnet_toolbox/Annotations/QtPolygonAnnotation.py +497 -353
  6. coralnet_toolbox/Annotations/QtRectangleAnnotation.py +126 -116
  7. coralnet_toolbox/AutoDistill/QtDeployModel.py +23 -12
  8. coralnet_toolbox/CoralNet/QtDownload.py +2 -1
  9. coralnet_toolbox/Explorer/QtDataItem.py +1 -1
  10. coralnet_toolbox/Explorer/QtExplorer.py +159 -17
  11. coralnet_toolbox/Explorer/QtSettingsWidgets.py +160 -86
  12. coralnet_toolbox/IO/QtExportTagLabAnnotations.py +30 -10
  13. coralnet_toolbox/IO/QtImportTagLabAnnotations.py +21 -15
  14. coralnet_toolbox/IO/QtOpenProject.py +46 -78
  15. coralnet_toolbox/IO/QtSaveProject.py +18 -43
  16. coralnet_toolbox/MachineLearning/DeployModel/QtDetect.py +22 -11
  17. coralnet_toolbox/MachineLearning/DeployModel/QtSegment.py +22 -10
  18. coralnet_toolbox/MachineLearning/ExportDataset/QtBase.py +61 -24
  19. coralnet_toolbox/MachineLearning/ExportDataset/QtClassify.py +5 -1
  20. coralnet_toolbox/MachineLearning/ExportDataset/QtDetect.py +19 -6
  21. coralnet_toolbox/MachineLearning/ExportDataset/QtSegment.py +21 -8
  22. coralnet_toolbox/MachineLearning/ImportDataset/QtBase.py +42 -22
  23. coralnet_toolbox/MachineLearning/VideoInference/QtBase.py +0 -4
  24. coralnet_toolbox/QtAnnotationWindow.py +42 -14
  25. coralnet_toolbox/QtEventFilter.py +19 -2
  26. coralnet_toolbox/QtImageWindow.py +134 -86
  27. coralnet_toolbox/QtLabelWindow.py +14 -2
  28. coralnet_toolbox/QtMainWindow.py +122 -9
  29. coralnet_toolbox/QtProgressBar.py +52 -27
  30. coralnet_toolbox/Rasters/QtRaster.py +59 -7
  31. coralnet_toolbox/Rasters/RasterTableModel.py +42 -14
  32. coralnet_toolbox/SAM/QtBatchInference.py +0 -2
  33. coralnet_toolbox/SAM/QtDeployGenerator.py +22 -11
  34. coralnet_toolbox/SAM/QtDeployPredictor.py +10 -0
  35. coralnet_toolbox/SeeAnything/QtBatchInference.py +19 -221
  36. coralnet_toolbox/SeeAnything/QtDeployGenerator.py +1634 -0
  37. coralnet_toolbox/SeeAnything/QtDeployPredictor.py +107 -154
  38. coralnet_toolbox/SeeAnything/QtTrainModel.py +115 -45
  39. coralnet_toolbox/SeeAnything/__init__.py +2 -0
  40. coralnet_toolbox/Tools/QtCutSubTool.py +18 -2
  41. coralnet_toolbox/Tools/QtResizeSubTool.py +19 -2
  42. coralnet_toolbox/Tools/QtSAMTool.py +222 -57
  43. coralnet_toolbox/Tools/QtSeeAnythingTool.py +223 -55
  44. coralnet_toolbox/Tools/QtSelectSubTool.py +6 -4
  45. coralnet_toolbox/Tools/QtSelectTool.py +27 -3
  46. coralnet_toolbox/Tools/QtSubtractSubTool.py +66 -0
  47. coralnet_toolbox/Tools/QtWorkAreaTool.py +25 -13
  48. coralnet_toolbox/Tools/__init__.py +2 -0
  49. coralnet_toolbox/__init__.py +1 -1
  50. coralnet_toolbox/utilities.py +137 -47
  51. coralnet_toolbox-0.0.74.dist-info/METADATA +375 -0
  52. {coralnet_toolbox-0.0.72.dist-info → coralnet_toolbox-0.0.74.dist-info}/RECORD +56 -53
  53. coralnet_toolbox-0.0.72.dist-info/METADATA +0 -341
  54. {coralnet_toolbox-0.0.72.dist-info → coralnet_toolbox-0.0.74.dist-info}/WHEEL +0 -0
  55. {coralnet_toolbox-0.0.72.dist-info → coralnet_toolbox-0.0.74.dist-info}/entry_points.txt +0 -0
  56. {coralnet_toolbox-0.0.72.dist-info → coralnet_toolbox-0.0.74.dist-info}/licenses/LICENSE.txt +0 -0
  57. {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, QGraphicsPolygonItem
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
- """Check if the point is within the annotation's bounding box."""
67
- half_size = self.annotation_size / 2
68
- rect = QRectF(self.center_xy.x() - half_size,
69
- self.center_xy.y() - half_size,
70
- self.annotation_size,
71
- self.annotation_size)
72
- return rect.contains(point)
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
- """Calculate the area of the square patch."""
80
- return self.annotation_size * self.annotation_size
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
- """Calculate the perimeter of the square patch."""
84
- return 4 * self.annotation_size
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 dotted outline and black background."""
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) # Transparent background
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 polygon from the cropped points
131
- polygon = QPolygonF(cropped_points)
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 polygon onto the mask
134
- painter.drawPolygon(polygon)
135
- painter.end()
194
+ # Draw the path onto the mask
195
+ mask_painter.drawPath(path)
196
+ mask_painter.end()
136
197
 
137
- # Convert the mask QImage to QPixmap and create a bitmap 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) # 50% opacity for outside the polygon
207
+ result_painter.setOpacity(0.5)
152
208
  result_painter.drawPixmap(0, 0, self.cropped_image)
153
209
 
154
- # Then draw the full opacity image only in the masked area (inside the polygon)
155
- result_painter.setOpacity(1.0) # Reset to full opacity
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 dotted line outline on top
215
+ # Draw the solid line outline on top
160
216
  pen = QPen(Qt.black)
161
- pen.setStyle(Qt.SolidLine) # Solid line
162
- pen.setWidth(1) # Line width
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.drawPolygon(polygon)
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 patch annotation and add them to the scene as a group."""
199
- # Use a polygon (square) as the main graphics item
200
- half_size = self.annotation_size / 2
201
- points = [
202
- QPointF(self.center_xy.x() - half_size, self.center_xy.y() - half_size), # Top-left
203
- QPointF(self.center_xy.x() + half_size, self.center_xy.y() - half_size), # Top-right
204
- QPointF(self.center_xy.x() + half_size, self.center_xy.y() + half_size), # Bottom-right
205
- QPointF(self.center_xy.x() - half_size, self.center_xy.y() + half_size), # Bottom-left
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
- # Use a polygon (square) as the main graphics item
214
- half_size = self.annotation_size / 2
215
- points = [
216
- QPointF(self.center_xy.x() - half_size, self.center_xy.y() - half_size), # Top-left
217
- QPointF(self.center_xy.x() + half_size, self.center_xy.y() - half_size), # Top-right
218
- QPointF(self.center_xy.x() + half_size, self.center_xy.y() + half_size), # Bottom-right
219
- QPointF(self.center_xy.x() - half_size, self.center_xy.y() + half_size), # Bottom-left
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 multiple annotations (patches and/or polygons) into a single polygon annotation.
263
- If any annotation is not touching at least one other, cancel the operation (return None).
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
- # Check that all annotations have the same label
269
- first_annotation = annotations[0]
270
- if not all(annotation.label.id == first_annotation.label.id for annotation in annotations):
271
- return None # Can't combine annotations with different labels
272
-
273
- # Separate patches and polygons
274
- patches = [annotation for annotation in annotations if isinstance(annotation, cls)]
275
- polygons = [annotation for annotation in annotations if not isinstance(annotation, cls)]
276
-
277
- # --- TOUCHING CHECK ---
278
- # For each annotation, check if it touches at least one other
279
- def patch_touches(a, b):
280
- # Use bounding box intersection for patches
281
- rect_a = QRectF(a.get_bounding_box_top_left(), a.get_bounding_box_bottom_right())
282
- rect_b = QRectF(b.get_bounding_box_top_left(), b.get_bounding_box_bottom_right())
283
- return rect_a.intersects(rect_b)
284
-
285
- def poly_touches(a, b):
286
- # Use polygon intersection for polygons
287
- poly_a = a.get_polygon()
288
- poly_b = b.get_polygon()
289
- return poly_a.intersected(poly_b).count() > 0
290
-
291
- for i, anno_i in enumerate(annotations):
292
- has_touch = False
293
- for j, anno_j in enumerate(annotations):
294
- if i == j:
295
- continue
296
- if isinstance(anno_i, cls) and isinstance(anno_j, cls):
297
- if patch_touches(anno_i, anno_j):
298
- has_touch = True
299
- break
300
- elif not isinstance(anno_i, cls) and not isinstance(anno_j, cls):
301
- if poly_touches(anno_i, anno_j):
302
- has_touch = True
303
- break
304
- else:
305
- # Patch vs Polygon: check if patch bbox intersects polygon
306
- patch = anno_i if isinstance(anno_i, cls) else anno_j
307
- poly = anno_j if isinstance(anno_i, cls) else anno_i
308
- rect_patch = QRectF(
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):