coralnet-toolbox 0.0.73__py2.py3-none-any.whl → 0.0.75__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 (50) 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/CoralNet/QtDownload.py +2 -1
  8. coralnet_toolbox/Explorer/QtDataItem.py +52 -22
  9. coralnet_toolbox/Explorer/QtExplorer.py +293 -1614
  10. coralnet_toolbox/Explorer/QtSettingsWidgets.py +203 -85
  11. coralnet_toolbox/Explorer/QtViewers.py +1568 -0
  12. coralnet_toolbox/Explorer/transformer_models.py +59 -0
  13. coralnet_toolbox/Explorer/yolo_models.py +112 -0
  14. coralnet_toolbox/IO/QtExportTagLabAnnotations.py +30 -10
  15. coralnet_toolbox/IO/QtImportTagLabAnnotations.py +21 -15
  16. coralnet_toolbox/IO/QtOpenProject.py +46 -78
  17. coralnet_toolbox/IO/QtSaveProject.py +18 -43
  18. coralnet_toolbox/MachineLearning/ExportDataset/QtBase.py +1 -1
  19. coralnet_toolbox/MachineLearning/ImportDataset/QtBase.py +253 -141
  20. coralnet_toolbox/MachineLearning/VideoInference/QtBase.py +0 -4
  21. coralnet_toolbox/MachineLearning/VideoInference/YOLO3D/run.py +102 -16
  22. coralnet_toolbox/QtAnnotationWindow.py +16 -10
  23. coralnet_toolbox/QtEventFilter.py +11 -0
  24. coralnet_toolbox/QtImageWindow.py +120 -75
  25. coralnet_toolbox/QtLabelWindow.py +13 -1
  26. coralnet_toolbox/QtMainWindow.py +5 -27
  27. coralnet_toolbox/QtProgressBar.py +52 -27
  28. coralnet_toolbox/Rasters/RasterTableModel.py +28 -8
  29. coralnet_toolbox/SAM/QtDeployGenerator.py +1 -4
  30. coralnet_toolbox/SAM/QtDeployPredictor.py +11 -3
  31. coralnet_toolbox/SeeAnything/QtDeployGenerator.py +805 -162
  32. coralnet_toolbox/SeeAnything/QtDeployPredictor.py +130 -151
  33. coralnet_toolbox/Tools/QtCutSubTool.py +18 -2
  34. coralnet_toolbox/Tools/QtPolygonTool.py +42 -3
  35. coralnet_toolbox/Tools/QtRectangleTool.py +30 -0
  36. coralnet_toolbox/Tools/QtResizeSubTool.py +19 -2
  37. coralnet_toolbox/Tools/QtSAMTool.py +72 -50
  38. coralnet_toolbox/Tools/QtSeeAnythingTool.py +8 -5
  39. coralnet_toolbox/Tools/QtSelectTool.py +27 -3
  40. coralnet_toolbox/Tools/QtSubtractSubTool.py +66 -0
  41. coralnet_toolbox/Tools/__init__.py +2 -0
  42. coralnet_toolbox/__init__.py +1 -1
  43. coralnet_toolbox/utilities.py +158 -47
  44. coralnet_toolbox-0.0.75.dist-info/METADATA +378 -0
  45. {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.75.dist-info}/RECORD +49 -44
  46. coralnet_toolbox-0.0.73.dist-info/METADATA +0 -341
  47. {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.75.dist-info}/WHEEL +0 -0
  48. {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.75.dist-info}/entry_points.txt +0 -0
  49. {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.75.dist-info}/licenses/LICENSE.txt +0 -0
  50. {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.75.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, QGraphicsPolygonItem
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 to 3 decimal places and apply polygon simplification.
57
+ Set the precision of the outer points and all inner holes.
55
58
 
56
59
  Args:
57
- points: List of QPointF vertices defining the polygon
58
- reduce: Whether to round coordinates to 3 decimal places
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
- # Then round the coordinates if requested
63
+ # Process and assign the outer boundary points
61
64
  if reduce:
62
- points = [QPointF(round(point.x(), 6), round(point.y(), 6)) for point in points]
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
- self.points = points
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
- """Calculate the centroid of the polygon defined by the points."""
68
- centroid_x = sum(point.x() for point in self.points) / len(self.points)
69
- centroid_y = sum(point.y() for point in self.points) / len(self.points)
70
- self.center_xy = QPointF(centroid_x, centroid_y)
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
- """Check if the given point is inside the polygon defined by the points."""
83
- polygon = QPolygonF(self.points)
84
- return polygon.containsPoint(point, Qt.OddEvenFill)
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
- """Calculate the area of the polygon defined by the points."""
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
- # Use the shoelace formula to calculate the area of the polygon
96
- area = 0.0
97
- n = len(self.points)
98
- for i in range(n):
99
- j = (i + 1) % n
100
- area += self.points[i].x() * self.points[j].y()
101
- area -= self.points[j].x() * self.points[i].y()
102
- return abs(area) / 2.0
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
- """Calculate the perimeter of the polygon defined by the points."""
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
- perimeter = 0.0
110
- n = len(self.points)
111
- for i in range(n):
112
- j = (i + 1) % n
113
- # Calculate Euclidean distance between points manually
114
- dx = self.points[i].x() - self.points[j].x()
115
- dy = self.points[i].y() - self.points[j].y()
116
- distance = math.sqrt(dx * dx + dy * dy)
117
- perimeter += distance
118
- return perimeter
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
- """Get the cropped image with the polygon mask applied and black background."""
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 a QImage with transparent background for the mask
138
- masked_image = QImage(self.cropped_image.size(), QImage.Format_ARGB32)
139
- masked_image.fill(Qt.transparent) # Transparent background
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
- # Create a QPainter to draw the polygon onto the mask
142
- painter = QPainter(masked_image)
143
- painter.setRenderHint(QPainter.Antialiasing)
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
- # Create a copy of the points that are transformed to be relative to the cropped_image
148
- cropped_points = [QPointF(point.x() - self.cropped_bbox[0],
149
- point.y() - self.cropped_bbox[1]) for point in self.points]
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 polygon from the cropped points
152
- polygon = QPolygonF(cropped_points)
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
- # Draw the polygon onto the mask
155
- painter.drawPolygon(polygon)
156
- painter.end()
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 QImage to QPixmap and create a bitmap 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
- # Create the result image
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
- result_painter.setOpacity(0.5) # 50% opacity for outside the polygon
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
- # Then draw the full opacity image only in the masked area (inside the polygon)
176
- result_painter.setOpacity(1.0) # Reset to full opacity
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 dotted line outline on top
290
+ # Draw the outline of the path (outer and inner boundaries)
181
291
  pen = QPen(Qt.black)
182
- pen.setStyle(Qt.SolidLine) # Solid line
183
- pen.setWidth(1) # Line width
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.drawPolygon(polygon)
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
- """Create all graphics items for the polygon annotation and add them to the scene as a group."""
218
- # Use a QGraphicsPolygonItem as the main graphics item
219
- self.graphics_item = QGraphicsPolygonItem(QPolygonF(self.points))
220
- # Call parent to handle group and helpers
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
- """Update the graphical representation of the polygon annotation."""
225
- # Use a QGraphicsPolygonItem as the main graphics item
226
- self.graphics_item = QGraphicsPolygonItem(QPolygonF(self.points))
227
- # Call parent to handle group and helpers
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
- xy_points = [(p.x(), p.y()) for p in self.points]
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
- updated_coords = simplify_polygon(xy_points, self.tolerance)
363
+ process_function = lambda pts: simplify_polygon(pts, self.tolerance)
241
364
  elif delta > 0:
242
365
  # Densify: decrease segment length (more detail)
243
- updated_coords = densify_polygon(xy_points)
366
+ process_function = densify_polygon
244
367
  else:
245
- updated_coords = xy_points
368
+ # No change
369
+ return
246
370
 
247
- updated_coords = [QPointF(x, y) for x, y in updated_coords]
248
- self.set_precision(updated_coords)
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
- """Update the location of the annotation by moving it to a new center point."""
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
- # Update the location, graphic
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 each vertex radially from the centroid.
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
- # Calculate centroid
281
- centroid_x = sum(p.x() for p in self.points) / len(self.points)
282
- centroid_y = sum(p.y() for p in self.points) / len(self.points)
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: small step for each call
285
- # If delta > 1, grow; if delta < 1, shrink; if delta == 1, no change
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
- # Move each point radially using the scale factor
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
- """Resize the annotation by moving a specific handle (vertex) to a new position."""
311
- # Clear the machine confidence
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
- # Extract the point index from the handle string (e.g., "point_0" -> 0)
315
- if handle.startswith("point_"):
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
- # Recalculate centroid and bounding box
323
- self.set_precision(new_points)
324
- self.set_centroid()
325
- self.set_cropped_bbox()
326
- self.update_graphics_item()
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
- # Notify that the annotation has been updated
329
- self.annotationUpdated.emit(self)
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
- """Combine annotations. Returns PolygonAnnotation (merged) or MultiPolygonAnnotation (disjoint)."""
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
- # Build an adjacency graph where an edge represents polygon overlap
341
- overlap_graph = {}
342
- for i in range(len(annotations)):
343
- overlap_graph[i] = set()
344
-
345
- # Check for overlap between polygons
346
- for i in range(len(annotations) - 1):
347
- poly1_points = np.array([(p.x(), p.y()) for p in annotations[i].points], dtype=np.int32)
348
-
349
- # Create a mask for the first polygon
350
- poly1_bbox = annotations[i].cropped_bbox
351
- p1_min_x, p1_min_y = int(poly1_bbox[0]), int(poly1_bbox[1])
352
- p1_max_x, p1_max_y = int(poly1_bbox[2]), int(poly1_bbox[3])
353
- p1_width = p1_max_x - p1_min_x + 20 # Add padding
354
- p1_height = p1_max_y - p1_min_y + 20
355
-
356
- # Adjust polygon coordinates to mask
357
- poly1_adjusted = poly1_points.copy()
358
- poly1_adjusted[:, 0] -= p1_min_x - 10
359
- poly1_adjusted[:, 1] -= p1_min_y - 10
360
-
361
- mask1 = np.zeros((p1_height, p1_width), dtype=np.uint8)
362
- cv2.fillPoly(mask1, [poly1_adjusted], 255)
363
-
364
- for j in range(i + 1, len(annotations)):
365
- poly2_points = np.array([(p.x(), p.y()) for p in annotations[j].points], dtype=np.int32)
366
-
367
- # First check bounding box overlap for quick filtering
368
- min_x1, min_y1, max_x1, max_y1 = annotations[i].cropped_bbox
369
- min_x2, min_y2, max_x2, max_y2 = annotations[j].cropped_bbox
370
-
371
- has_overlap = False
372
-
373
- # Check if bounding boxes overlap
374
- if not (max_x1 < min_x2 or max_x2 < min_x1 or max_y1 < min_y2 or max_y2 < min_y1):
375
- # Create a mask for the second polygon in the same coordinate system as the first
376
- poly2_adjusted = poly2_points.copy()
377
- poly2_adjusted[:, 0] -= p1_min_x - 10
378
- poly2_adjusted[:, 1] -= p1_min_y - 10
379
-
380
- mask2 = np.zeros_like(mask1)
381
- cv2.fillPoly(mask2, [poly2_adjusted], 255)
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
- return new_annotation
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
- """Cut a polygon annotation where it intersects with a cutting line.
534
-
535
- Args:
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
- # Extract polygon points as (x,y) tuples
546
- polygon_points = [(point.x(), point.y()) for point in annotation.points]
547
- if len(polygon_points) < 3:
548
- return [annotation] # Not a valid polygon
549
-
550
- # Create shapely polygon
551
- polygon = Polygon(polygon_points)
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 (do NOT extend)
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 along the cutting line (no extension)
563
- split_polygons = split(polygon, cutting_line)
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
- for geom in split_polygons.geoms:
570
- # Skip tiny fragments
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
- # Get the exterior coordinates of the polygon
575
- coords = list(geom.exterior.coords)
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
- # Convert coordinates to QPointF objects
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 polygon annotation
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 representation for serialization."""
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 representation."""
755
+ """Create a PolygonAnnotation object from a dictionary, including holes."""
619
756
  points = [QPointF(x, y) for x, y in data['points']]
620
- annotation = cls(points,
621
- data['label_short_code'],
622
- data['label_long_code'],
623
- QColor(*data['annotation_color']),
624
- data['image_path'],
625
- data['label_id'])
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
- # Convert machine_confidence keys back to Label objects
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