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
@@ -71,9 +71,17 @@ class SeeAnythingTool(Tool):
71
71
  self.current_rect_graphics = None # For the rectangle currently being drawn
72
72
  self.rectangles_processed = False # Track if rectangles have been processed
73
73
 
74
+ # Add state variables for custom working area creation
75
+ self.creating_working_area = False
76
+ self.working_area_start = None
77
+ self.working_area_temp_graphics = None
78
+
79
+ # Add hover position tracking
80
+ self.hover_pos = None
81
+
74
82
  self.annotations = []
75
83
  self.results = None
76
-
84
+
77
85
  def activate(self):
78
86
  """
79
87
  Activates the tool.
@@ -98,6 +106,9 @@ class SeeAnythingTool(Tool):
98
106
 
99
107
  # Clean up working area and shadow
100
108
  self.cancel_working_area()
109
+
110
+ # Cancel working area creation if in progress
111
+ self.cancel_working_area_creation()
101
112
 
102
113
  # Clear detection data
103
114
  self.results = None
@@ -162,9 +173,121 @@ class SeeAnythingTool(Tool):
162
173
 
163
174
  # Set the image in the SeeAnything dialog
164
175
  self.see_anything_dialog.set_image(self.work_area_image, self.image_path)
176
+ # self.see_anything_dialog.reload_model()
165
177
 
166
178
  self.annotation_window.setCursor(Qt.CrossCursor)
167
179
  self.annotation_window.scene.update()
180
+
181
+ def set_custom_working_area(self, start_point, end_point):
182
+ """
183
+ Create a working area from custom points selected by the user.
184
+
185
+ Args:
186
+ start_point (QPointF): First corner of the working area
187
+ end_point (QPointF): Opposite corner of the working area
188
+ """
189
+ self.annotation_window.setCursor(Qt.WaitCursor)
190
+
191
+ # Cancel any existing working area
192
+ self.cancel_working_area()
193
+
194
+ # Calculate the rectangle bounds
195
+ left = max(0, int(min(start_point.x(), end_point.x())))
196
+ top = max(0, int(min(start_point.y(), end_point.y())))
197
+ right = min(int(self.annotation_window.pixmap_image.size().width()),
198
+ int(max(start_point.x(), end_point.x())))
199
+ bottom = min(int(self.annotation_window.pixmap_image.size().height()),
200
+ int(max(start_point.y(), end_point.y())))
201
+
202
+ # Ensure minimum size (at least 10x10 pixels)
203
+ if right - left < 10:
204
+ right = min(left + 10, int(self.annotation_window.pixmap_image.size().width()))
205
+ if bottom - top < 10:
206
+ bottom = min(top + 10, int(self.annotation_window.pixmap_image.size().height()))
207
+
208
+ # Original image information
209
+ self.image_path = self.annotation_window.current_image_path
210
+ self.original_image = pixmap_to_numpy(self.annotation_window.pixmap_image)
211
+ self.original_width = self.annotation_window.pixmap_image.size().width()
212
+ self.original_height = self.annotation_window.pixmap_image.size().height()
213
+
214
+ # Create the WorkArea instance
215
+ self.working_area = WorkArea(left, top, right - left, bottom - top, self.image_path)
216
+
217
+ # Get the thickness for the working area graphics
218
+ pen_width = self.graphics_utility.get_workarea_thickness(self.annotation_window)
219
+
220
+ # Create and add the working area graphics
221
+ self.working_area.create_graphics(self.annotation_window.scene, pen_width)
222
+ self.working_area.set_remove_button_visibility(False)
223
+ self.working_area.removed.connect(self.on_working_area_removed)
224
+
225
+ # Create shadow overlay
226
+ shadow_brush = QBrush(QColor(0, 0, 0, 150))
227
+ shadow_path = QPainterPath()
228
+ shadow_path.addRect(self.annotation_window.scene.sceneRect())
229
+ shadow_path.addRect(self.working_area.rect)
230
+ shadow_path = shadow_path.simplified()
231
+
232
+ self.shadow_area = QGraphicsPathItem(shadow_path)
233
+ self.shadow_area.setBrush(shadow_brush)
234
+ self.shadow_area.setPen(QPen(Qt.NoPen))
235
+ self.annotation_window.scene.addItem(self.shadow_area)
236
+
237
+ # Crop the image based on the working area
238
+ self.work_area_image = self.original_image[top:bottom, left:right]
239
+
240
+ # Set the image in the SeeAnything dialog
241
+ self.see_anything_dialog.set_image(self.work_area_image, self.image_path)
242
+
243
+ self.annotation_window.setCursor(Qt.CrossCursor)
244
+ self.annotation_window.scene.update()
245
+
246
+ def display_working_area_preview(self, current_pos):
247
+ """
248
+ Display a preview rectangle for the working area being created.
249
+
250
+ Args:
251
+ current_pos (QPointF): Current mouse position
252
+ """
253
+ if not self.working_area_start:
254
+ return
255
+
256
+ # Remove previous preview if it exists
257
+ if self.working_area_temp_graphics:
258
+ self.annotation_window.scene.removeItem(self.working_area_temp_graphics)
259
+ self.working_area_temp_graphics = None
260
+
261
+ # Create preview rectangle
262
+ rect = QRectF(
263
+ min(self.working_area_start.x(), current_pos.x()),
264
+ min(self.working_area_start.y(), current_pos.y()),
265
+ abs(current_pos.x() - self.working_area_start.x()),
266
+ abs(current_pos.y() - self.working_area_start.y())
267
+ )
268
+
269
+ # Create a dashed blue pen for the working area preview
270
+ pen = QPen(QColor(0, 120, 215))
271
+ pen.setStyle(Qt.DashLine)
272
+ pen.setWidth(2)
273
+
274
+ self.working_area_temp_graphics = QGraphicsRectItem(rect)
275
+ self.working_area_temp_graphics.setPen(pen)
276
+ self.working_area_temp_graphics.setBrush(QBrush(QColor(0, 120, 215, 30))) # Light blue transparent fill
277
+ self.annotation_window.scene.addItem(self.working_area_temp_graphics)
278
+
279
+ def cancel_working_area_creation(self):
280
+ """
281
+ Cancel the process of creating a working area.
282
+ """
283
+ self.creating_working_area = False
284
+ self.working_area_start = None
285
+
286
+ if self.working_area_temp_graphics:
287
+ self.annotation_window.scene.removeItem(self.working_area_temp_graphics)
288
+ self.working_area_temp_graphics = None
289
+
290
+ self.annotation_window.scene.update()
168
291
 
169
292
  def on_working_area_removed(self, work_area):
170
293
  """
@@ -277,12 +400,25 @@ class SeeAnythingTool(Tool):
277
400
  "A label must be selected before adding an annotation.")
278
401
  return None
279
402
 
403
+ # Get position in scene coordinates
404
+ scene_pos = self.annotation_window.mapToScene(event.pos())
405
+
406
+ # Handle working area creation mode
407
+ if not self.working_area and event.button() == Qt.LeftButton:
408
+ if not self.creating_working_area:
409
+ # Start working area creation
410
+ self.creating_working_area = True
411
+ self.working_area_start = scene_pos
412
+ return
413
+ elif self.creating_working_area and self.working_area_start:
414
+ # Finish working area creation
415
+ self.set_custom_working_area(self.working_area_start, scene_pos)
416
+ self.cancel_working_area_creation()
417
+ return
418
+
280
419
  if not self.working_area:
281
420
  return
282
421
 
283
- # Position in the scene
284
- scene_pos = self.annotation_window.mapToScene(event.pos())
285
-
286
422
  # Check if the position is within the working area
287
423
  if not self.working_area.contains_point(scene_pos):
288
424
  return
@@ -319,6 +455,14 @@ class SeeAnythingTool(Tool):
319
455
  Args:
320
456
  event (QMouseEvent): The mouse move event.
321
457
  """
458
+ scene_pos = self.annotation_window.mapToScene(event.pos())
459
+ self.hover_pos = scene_pos
460
+
461
+ # Update working area preview during creation
462
+ if self.creating_working_area and self.working_area_start:
463
+ self.display_working_area_preview(scene_pos)
464
+ return
465
+
322
466
  if self.working_area and self.drawing_rectangle:
323
467
  # Update the end point while drawing the rectangle
324
468
  self.end_point = self.annotation_window.mapToScene(event.pos())
@@ -334,6 +478,12 @@ class SeeAnythingTool(Tool):
334
478
  event (QKeyEvent): The key press event
335
479
  """
336
480
  if event.key() == Qt.Key_Space:
481
+ # If creating working area, confirm it
482
+ if self.creating_working_area and self.working_area_start and self.hover_pos:
483
+ self.set_custom_working_area(self.working_area_start, self.hover_pos)
484
+ self.cancel_working_area_creation()
485
+ return
486
+
337
487
  # If there is no working area, set it
338
488
  if not self.working_area:
339
489
  self.set_working_area()
@@ -359,6 +509,11 @@ class SeeAnythingTool(Tool):
359
509
  self.cancel_working_area()
360
510
 
361
511
  elif event.key() == Qt.Key_Backspace:
512
+ # If creating working area, cancel it
513
+ if self.creating_working_area:
514
+ self.cancel_working_area_creation()
515
+ return
516
+
362
517
  # Cancel current rectangle being drawn
363
518
  if self.drawing_rectangle:
364
519
  self.drawing_rectangle = False
@@ -398,8 +553,16 @@ class SeeAnythingTool(Tool):
398
553
  # Move the points back to the original image space
399
554
  working_area_top_left = self.working_area.rect.topLeft()
400
555
 
401
- # Predict the mask provided prompts (using only the current user-drawn rectangles)
402
- results = self.see_anything_dialog.predict_from_prompts(self.rectangles)[0]
556
+ masks = None
557
+ # Create masks from the rectangles (these are not polygons)
558
+ if self.see_anything_dialog.task_dropdown.currentText() == 'segment':
559
+ masks = []
560
+ for r in self.rectangles:
561
+ x1, y1, x2, y2 = r
562
+ masks.append(np.array([[x1, y1], [x2, y1], [x2, y2], [x1, y2]]))
563
+
564
+ # Predict from prompts, providing masks if the task is segmentation
565
+ results = self.see_anything_dialog.predict_from_prompts(self.rectangles, masks=masks)[0]
403
566
 
404
567
  if not results:
405
568
  # Make cursor normal
@@ -425,54 +588,60 @@ class SeeAnythingTool(Tool):
425
588
  # Clear previous annotations if any
426
589
  self.clear_annotations()
427
590
 
428
- # Loop through the results from the current prediction
429
- for result in self.results:
430
- # Extract values from result
431
- confidence = result.boxes.conf.item()
432
-
433
- if confidence < self.main_window.get_uncertainty_thresh():
434
- continue
435
-
436
- # Get the bounding box coordinates (x1, y1, x2, y2) in normalized format
437
- box = result.boxes.xyxyn.detach().cpu().numpy().squeeze()
438
-
439
- # Convert from normalized coordinates directly to absolute pixel coordinates in the whole image
440
- box_abs = box.copy() * np.array([self.work_area_image.shape[1],
441
- self.work_area_image.shape[0],
442
- self.work_area_image.shape[1],
443
- self.work_area_image.shape[0]])
444
-
445
- # Add working area offset to get coordinates in the whole image
446
- box_abs[0] += working_area_top_left.x()
447
- box_abs[1] += working_area_top_left.y()
448
- box_abs[2] += working_area_top_left.x()
449
- box_abs[3] += working_area_top_left.y()
450
-
451
- # Check box area relative to **work area view** area
452
- box_area = (box_abs[2] - box_abs[0]) * (box_abs[3] - box_abs[1])
453
-
454
- # self.main_window.get_area_thresh_min()
455
- if box_area < self.main_window.get_area_thresh_min() * image_area:
456
- continue
457
-
458
- if box_area > self.main_window.get_area_thresh_max() * image_area:
459
- continue
460
-
461
- if self.see_anything_dialog.task == "segment":
462
- # Use polygons from result.masks.data.xyn (list of polygons, each Nx2, normalized to crop)
463
- polygon = result.masks.xyn[0] # np.array of polygons, each as Nx2 array
464
-
465
- # Convert normalized polygon points directly to whole image coordinates
466
- polygon[:, 0] = polygon[:, 0] * self.work_area_image.shape[1] + working_area_top_left.x()
467
- polygon[:, 1] = polygon[:, 1] * self.work_area_image.shape[0] + working_area_top_left.y()
468
-
469
- polygon = simplify_polygon(polygon, 0.1)
470
-
471
- # Create the polygon annotation and add it to self.annotations
472
- self.create_polygon_annotation(polygon, confidence)
473
- else:
474
- # Create the rectangle annotation and add it to self.annotations
475
- self.create_rectangle_annotation(box_abs, confidence)
591
+ # Process results based on the task type (creates polygons or rectangle annotations)
592
+ if self.see_anything_dialog.task_dropdown.currentText() == "segment":
593
+ if self.results.masks:
594
+ for i, polygon in enumerate(self.results.masks.xyn):
595
+ confidence = self.results.boxes.conf[i].item()
596
+ if confidence < self.main_window.get_uncertainty_thresh():
597
+ continue
598
+
599
+ # Get absolute bounding box for area check (relative to work area)
600
+ box_work_area = self.results.boxes.xyxy[i].detach().cpu().numpy()
601
+ box_area = (box_work_area[2] - box_work_area[0]) * (box_work_area[3] - box_work_area[1])
602
+
603
+ # Area filtering
604
+ min_area = self.main_window.get_area_thresh_min() * image_area
605
+ max_area = self.main_window.get_area_thresh_max() * image_area
606
+ if not (min_area <= box_area <= max_area):
607
+ continue
608
+
609
+ # Convert normalized polygon points to absolute coordinates in the whole image
610
+ polygon_abs = polygon.copy()
611
+ polygon_abs[:, 0] = polygon_abs[:, 0] * self.work_area_image.shape[1] + working_area_top_left.x()
612
+ polygon_abs[:, 1] = polygon_abs[:, 1] * self.work_area_image.shape[0] + working_area_top_left.y()
613
+
614
+ polygon_abs = simplify_polygon(polygon_abs, 0.1)
615
+ self.create_polygon_annotation(polygon_abs, confidence)
616
+
617
+ else: # Task is 'detect'
618
+ if self.results.boxes:
619
+ for i, box_norm in enumerate(self.results.boxes.xyxyn):
620
+ confidence = self.results.boxes.conf[i].item()
621
+ if confidence < self.main_window.get_uncertainty_thresh():
622
+ continue
623
+
624
+ # Convert normalized box to absolute coordinates in the work area
625
+ box_abs_work_area = box_norm.detach().cpu().numpy() * np.array(
626
+ [self.work_area_image.shape[1], self.work_area_image.shape[0],
627
+ self.work_area_image.shape[1], self.work_area_image.shape[0]])
628
+ # Calculate the area of the bounding box
629
+ box_area = (box_abs_work_area[2] - box_abs_work_area[0]) * \
630
+ (box_abs_work_area[3] - box_abs_work_area[1])
631
+
632
+ # Area filtering
633
+ min_area = self.main_window.get_area_thresh_min() * image_area
634
+ max_area = self.main_window.get_area_thresh_max() * image_area
635
+ if not (min_area <= box_area <= max_area):
636
+ continue
637
+
638
+ # Add working area offset to get coordinates in the whole image
639
+ box_abs_full = box_abs_work_area.copy()
640
+ box_abs_full[0] += working_area_top_left.x()
641
+ box_abs_full[1] += working_area_top_left.y()
642
+ box_abs_full[2] += working_area_top_left.x()
643
+ box_abs_full[3] += working_area_top_left.y()
644
+ self.create_rectangle_annotation(box_abs_full, confidence)
476
645
 
477
646
  self.annotation_window.scene.update()
478
647
 
@@ -750,4 +919,3 @@ class SeeAnythingTool(Tool):
750
919
 
751
920
  # Force update to ensure graphics are removed visually
752
921
  self.annotation_window.scene.update()
753
-
@@ -1,5 +1,5 @@
1
1
  from PyQt5.QtCore import Qt, QRectF
2
- from PyQt5.QtGui import QPen, QColor
2
+ from PyQt5.QtGui import QPen, QColor, QBrush
3
3
  from PyQt5.QtWidgets import QGraphicsRectItem
4
4
 
5
5
  from coralnet_toolbox.Tools.QtSubTool import SubTool
@@ -22,15 +22,17 @@ class SelectSubTool(SubTool):
22
22
  super().activate(event)
23
23
  self.selection_start_pos = self.annotation_window.mapToScene(event.pos())
24
24
 
25
- # Create and style the selection rectangle
25
+ # Create and style the selection rectangle (dashed blue, light blue fill)
26
26
  self.selection_rectangle = QGraphicsRectItem()
27
27
  width = self.parent_tool.graphics_utility.get_rectangle_graphic_thickness(self.annotation_window)
28
- pen = QPen(QColor(255, 255, 255), 2, Qt.DashLine)
28
+ pen = QPen(QColor(0, 120, 215))
29
+ pen.setStyle(Qt.DashLine)
29
30
  pen.setWidth(width)
30
31
  self.selection_rectangle.setPen(pen)
32
+ self.selection_rectangle.setBrush(QBrush(QColor(0, 120, 215, 30))) # Light blue transparent fill
31
33
  self.selection_rectangle.setRect(QRectF(self.selection_start_pos, self.selection_start_pos))
32
34
  self.annotation_window.scene.addItem(self.selection_rectangle)
33
-
35
+
34
36
  def deactivate(self):
35
37
  super().deactivate()
36
38
  if self.selection_rectangle:
@@ -11,6 +11,7 @@ from coralnet_toolbox.Tools.QtMoveSubTool import MoveSubTool
11
11
  from coralnet_toolbox.Tools.QtResizeSubTool import ResizeSubTool
12
12
  from coralnet_toolbox.Tools.QtSelectSubTool import SelectSubTool
13
13
  from coralnet_toolbox.Tools.QtCutSubTool import CutSubTool
14
+ from coralnet_toolbox.Tools.QtSubtractSubTool import SubtractSubTool
14
15
 
15
16
  from coralnet_toolbox.Annotations import (PatchAnnotation,
16
17
  PolygonAnnotation,
@@ -45,6 +46,9 @@ class SelectTool(Tool):
45
46
  self.resize_subtool = ResizeSubTool(self)
46
47
  self.select_subtool = SelectSubTool(self)
47
48
  self.cut_subtool = CutSubTool(self)
49
+ self.subtract_subtool = SubtractSubTool(self)
50
+
51
+ # --- State for the currently active sub-tool ---
48
52
  self.active_subtool: SubTool | None = None
49
53
 
50
54
  # --- State for transient UI (like resize handles) ---
@@ -175,9 +179,14 @@ class SelectTool(Tool):
175
179
  if modifiers & Qt.ShiftModifier and len(self.selected_annotations) == 1:
176
180
  self._show_resize_handles()
177
181
 
178
- # Ctrl+X: Start cutting mode
179
- if event.key() == Qt.Key_X and len(self.selected_annotations) == 1:
180
- self.set_active_subtool(self.cut_subtool, event, annotation=self.selected_annotations[0])
182
+ # --- Ctrl+X Hotkey Overload ---
183
+ if event.key() == Qt.Key_X:
184
+ if len(self.selected_annotations) > 1:
185
+ # If more than one annotation is selected, perform subtraction.
186
+ self.subtract_selected_annotations(event)
187
+ elif len(self.selected_annotations) == 1:
188
+ # If only one is selected, start cutting mode.
189
+ self.set_active_subtool(self.cut_subtool, event, annotation=self.selected_annotations[0])
181
190
 
182
191
  # Ctrl+C: Combine selected annotations
183
192
  elif event.key() == Qt.Key_C and len(self.selected_annotations) > 1:
@@ -315,6 +324,16 @@ class SelectTool(Tool):
315
324
  annotation.update_user_confidence(top_label)
316
325
  if len(self.selected_annotations) == 1:
317
326
  self.annotation_window.main_window.confidence_window.refresh_display()
327
+
328
+ def subtract_selected_annotations(self, event):
329
+ """
330
+ Initiates the subtraction operation by activating the SubtractSubTool.
331
+ """
332
+ self.set_active_subtool(
333
+ self.subtract_subtool,
334
+ event,
335
+ selected_annotations=self.selected_annotations.copy()
336
+ )
318
337
 
319
338
  def combine_selected_annotations(self):
320
339
  """Combine multiple selected annotations of the same type."""
@@ -437,6 +456,11 @@ class SelectTool(Tool):
437
456
  # Add the newly created annotations from the cut.
438
457
  for new_anno in new_annotations:
439
458
  self.annotation_window.add_annotation_from_tool(new_anno)
459
+
460
+ def cancel_cutting_mode(self):
461
+ """Safely cancels cutting mode."""
462
+ if self.active_subtool and isinstance(self.active_subtool, CutSubTool):
463
+ self.deactivate_subtool()
440
464
 
441
465
  # --- Convenience Properties ---
442
466
  @property
@@ -0,0 +1,66 @@
1
+ from coralnet_toolbox.Tools.QtSubTool import SubTool
2
+ from coralnet_toolbox.Annotations import PolygonAnnotation
3
+ from coralnet_toolbox.Annotations import MultiPolygonAnnotation
4
+
5
+
6
+ # ----------------------------------------------------------------------------------------------------------------------
7
+ # Classes
8
+ # ----------------------------------------------------------------------------------------------------------------------
9
+
10
+ class SubtractSubTool(SubTool):
11
+ """
12
+ A SubTool to perform a "cookie cutter" subtraction operation on selected annotations.
13
+ This tool activates, performs its action, and immediately deactivates.
14
+ """
15
+
16
+ def __init__(self, parent_tool):
17
+ super().__init__(parent_tool)
18
+
19
+ def activate(self, event, **kwargs):
20
+ """
21
+ Activates the subtraction operation.
22
+ Expects 'selected_annotations' in kwargs.
23
+ """
24
+ super().activate(event)
25
+
26
+ selected_annotations = kwargs.get('selected_annotations', [])
27
+
28
+ # --- 1. Perform Pre-activation Checks ---
29
+ if len(selected_annotations) < 2:
30
+ self.parent_tool.deactivate_subtool()
31
+ return
32
+
33
+ # Check if all annotations are verified Polygon or MultiPolygon Annotations
34
+ allowed_types = (PolygonAnnotation, MultiPolygonAnnotation)
35
+ if not all(isinstance(anno, allowed_types) and anno.verified for anno in selected_annotations):
36
+ self.parent_tool.deactivate_subtool()
37
+ return
38
+
39
+ # --- 2. Identify Base and Cutters ---
40
+ # The last selected annotation is the base, the rest are cutters.
41
+ base_annotation = selected_annotations[-1]
42
+ cutter_annotations = selected_annotations[:-1]
43
+
44
+ # --- 3. Perform the Subtraction ---
45
+ result_annotations = PolygonAnnotation.subtract(base_annotation, cutter_annotations)
46
+
47
+ if not result_annotations:
48
+ self.parent_tool.deactivate_subtool()
49
+ return
50
+
51
+ # --- 4. Update the Annotation Window ---
52
+ # Delete the original annotations that were used in the operation
53
+ for anno in selected_annotations:
54
+ self.annotation_window.delete_annotation(anno.id)
55
+
56
+ # Add the new resulting annotations to the scene
57
+ for new_anno in result_annotations:
58
+ self.annotation_window.add_annotation_from_tool(new_anno)
59
+
60
+ # Select the new annotations
61
+ self.annotation_window.unselect_annotations()
62
+ for new_anno in result_annotations:
63
+ self.annotation_window.select_annotation(new_anno, multi_select=True)
64
+
65
+ # --- 5. Deactivate Immediately ---
66
+ self.parent_tool.deactivate_subtool()
@@ -1,7 +1,7 @@
1
1
  import warnings
2
2
 
3
- from PyQt5.QtCore import Qt, QPointF, QRectF
4
- from PyQt5.QtGui import QMouseEvent, QPen, QColor
3
+ from PyQt5.QtCore import Qt, QRectF
4
+ from PyQt5.QtGui import QMouseEvent, QPen, QColor, QBrush
5
5
  from PyQt5.QtWidgets import (QGraphicsRectItem, QMessageBox, QGraphicsPixmapItem)
6
6
 
7
7
  from coralnet_toolbox.Tools.QtTool import Tool
@@ -30,8 +30,8 @@ class WorkAreaTool(Tool):
30
30
  self.current_rect = None
31
31
  self.work_areas = [] # List to store WorkArea objects for the current image
32
32
 
33
- # Style settings for drawing the work area rectangle
34
- self.work_area_pen = QPen(QColor(255, 255, 255), 2, Qt.DashLine)
33
+ # Style settings for drawing the work area rectangle - update to use blue dashed line
34
+ self.work_area_pen = QPen(QColor(0, 120, 215), 2, Qt.DashLine)
35
35
 
36
36
  # Track if Ctrl key is pressed
37
37
  self.ctrl_pressed = False
@@ -43,6 +43,9 @@ class WorkAreaTool(Tool):
43
43
  # Track current image path to detect image changes
44
44
  self.current_image_path = None
45
45
 
46
+ # Add hover position tracking for preview
47
+ self.hover_pos = None
48
+
46
49
  def activate(self):
47
50
  """Activate the work area tool and set the appropriate cursor."""
48
51
  self.active = True
@@ -100,10 +103,12 @@ class WorkAreaTool(Tool):
100
103
 
101
104
  def mouseMoveEvent(self, event: QMouseEvent):
102
105
  """Handle mouse move events to update the work area while drawing."""
106
+ scene_pos = self.annotation_window.mapToScene(event.pos())
107
+ self.hover_pos = scene_pos # Track hover position for spacebar confirmation
108
+
103
109
  if not self.drawing or not self.annotation_window.active_image:
104
110
  return
105
111
 
106
- scene_pos = self.annotation_window.mapToScene(event.pos())
107
112
  self.update_drawing(scene_pos)
108
113
 
109
114
  def mouseReleaseEvent(self, event: QMouseEvent):
@@ -116,6 +121,11 @@ class WorkAreaTool(Tool):
116
121
  modifiers = event.modifiers()
117
122
  key = event.key()
118
123
 
124
+ # Confirm current drawing with spacebar
125
+ if key == Qt.Key_Space and self.drawing and self.hover_pos:
126
+ self.finish_drawing(self.hover_pos)
127
+ return
128
+
119
129
  # Ctrl+Alt for temporary work area
120
130
  if (modifiers & Qt.ControlModifier) and (modifiers & Qt.AltModifier):
121
131
  if not self.temporary_work_area:
@@ -139,12 +149,12 @@ class WorkAreaTool(Tool):
139
149
  return
140
150
 
141
151
  # Ctrl+Space to create a work area from current view
142
- if key == Qt.Key_Space and self.annotation_window.active_image:
152
+ if key == Qt.Key_Space and self.annotation_window.active_image and not self.drawing:
143
153
  self.create_work_area_from_current_view()
144
154
  return
145
155
 
146
- # Cancel current drawing (Backspace - without modifiers)
147
- if key == Qt.Key_Backspace and self.drawing and not (modifiers & Qt.ControlModifier):
156
+ # Cancel current drawing (Backspace or Escape - without modifiers)
157
+ if (key == Qt.Key_Backspace or key == Qt.Key_Escape) and self.drawing and not (modifiers & Qt.ControlModifier):
148
158
  self.cancel_drawing()
149
159
  return
150
160
 
@@ -207,12 +217,13 @@ class WorkAreaTool(Tool):
207
217
  # Create an initial rectangle item for visual feedback
208
218
  self.current_rect = QGraphicsRectItem(QRectF(pos.x(), pos.y(), 0, 0))
209
219
 
210
- # Get the width
211
- width = self.graphics_utility.get_rectangle_graphic_thickness(self.annotation_window)
220
+ # Create a dashed blue pen for the working area preview
221
+ pen = QPen(QColor(0, 120, 215))
222
+ pen.setStyle(Qt.DashLine)
223
+ pen.setWidth(2)
212
224
 
213
- # Set the pen properties
214
- self.work_area_pen.setWidth(width)
215
- self.current_rect.setPen(self.work_area_pen)
225
+ self.current_rect.setPen(pen)
226
+ self.current_rect.setBrush(QBrush(QColor(0, 120, 215, 30))) # Light blue transparent fill
216
227
  self.annotation_window.scene.addItem(self.current_rect)
217
228
 
218
229
  def update_drawing(self, pos):
@@ -227,6 +238,7 @@ class WorkAreaTool(Tool):
227
238
  constrained_rect = self.constrain_rect_to_image_bounds(rect)
228
239
 
229
240
  self.current_rect.setRect(constrained_rect)
241
+ self.hover_pos = pos # Update hover position for key events
230
242
 
231
243
  def finish_drawing(self, pos):
232
244
  """Finish drawing the work area and add it to the list."""
@@ -14,6 +14,7 @@ from .QtCutSubTool import CutSubTool
14
14
  from .QtMoveSubTool import MoveSubTool
15
15
  from .QtResizeSubTool import ResizeSubTool
16
16
  from .QtSelectSubTool import SelectSubTool
17
+ from .QtSubtractSubTool import SubtractSubTool
17
18
 
18
19
  __all__ = [
19
20
  'PanTool',
@@ -29,4 +30,5 @@ __all__ = [
29
30
  'MoveSubTool',
30
31
  'ResizeSubTool',
31
32
  'SelectSubTool',
33
+ 'SubtractSubTool',
32
34
  ]
@@ -1,6 +1,6 @@
1
1
  """Top-level package for CoralNet-Toolbox."""
2
2
 
3
- __version__ = "0.0.72"
3
+ __version__ = "0.0.74"
4
4
  __author__ = "Jordan Pierce"
5
5
  __email__ = "jordan.pierce@noaa.gov"
6
6
  __credits__ = "National Center for Coastal and Ocean Sciences (NCCOS)"