coralnet-toolbox 0.0.73__py2.py3-none-any.whl → 0.0.74__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) 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/QtExplorer.py +16 -14
  9. coralnet_toolbox/Explorer/QtSettingsWidgets.py +114 -82
  10. coralnet_toolbox/IO/QtExportTagLabAnnotations.py +30 -10
  11. coralnet_toolbox/IO/QtImportTagLabAnnotations.py +21 -15
  12. coralnet_toolbox/IO/QtOpenProject.py +46 -78
  13. coralnet_toolbox/IO/QtSaveProject.py +18 -43
  14. coralnet_toolbox/MachineLearning/ExportDataset/QtBase.py +1 -1
  15. coralnet_toolbox/MachineLearning/ImportDataset/QtBase.py +42 -22
  16. coralnet_toolbox/MachineLearning/VideoInference/QtBase.py +0 -4
  17. coralnet_toolbox/QtEventFilter.py +11 -0
  18. coralnet_toolbox/QtImageWindow.py +117 -68
  19. coralnet_toolbox/QtLabelWindow.py +13 -1
  20. coralnet_toolbox/QtMainWindow.py +5 -27
  21. coralnet_toolbox/QtProgressBar.py +52 -27
  22. coralnet_toolbox/Rasters/RasterTableModel.py +8 -8
  23. coralnet_toolbox/SAM/QtDeployPredictor.py +10 -0
  24. coralnet_toolbox/SeeAnything/QtDeployGenerator.py +779 -161
  25. coralnet_toolbox/SeeAnything/QtDeployPredictor.py +86 -149
  26. coralnet_toolbox/Tools/QtCutSubTool.py +18 -2
  27. coralnet_toolbox/Tools/QtResizeSubTool.py +19 -2
  28. coralnet_toolbox/Tools/QtSAMTool.py +72 -50
  29. coralnet_toolbox/Tools/QtSeeAnythingTool.py +8 -5
  30. coralnet_toolbox/Tools/QtSelectTool.py +27 -3
  31. coralnet_toolbox/Tools/QtSubtractSubTool.py +66 -0
  32. coralnet_toolbox/Tools/__init__.py +2 -0
  33. coralnet_toolbox/__init__.py +1 -1
  34. coralnet_toolbox/utilities.py +137 -47
  35. coralnet_toolbox-0.0.74.dist-info/METADATA +375 -0
  36. {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.74.dist-info}/RECORD +40 -38
  37. coralnet_toolbox-0.0.73.dist-info/METADATA +0 -341
  38. {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.74.dist-info}/WHEEL +0 -0
  39. {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.74.dist-info}/entry_points.txt +0 -0
  40. {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.74.dist-info}/licenses/LICENSE.txt +0 -0
  41. {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.74.dist-info}/top_level.txt +0 -0
@@ -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()
@@ -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.73"
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)"
@@ -9,14 +9,14 @@ import requests
9
9
  import traceback
10
10
  from functools import lru_cache
11
11
 
12
+ import cv2
12
13
  import torch
13
14
  import numpy as np
14
15
 
15
16
  import rasterio
16
17
  from rasterio.windows import Window
17
18
 
18
- from shapely.validation import make_valid
19
- from shapely.geometry import Polygon, MultiPolygon, LineString, GeometryCollection
19
+ from shapely.geometry import Polygon
20
20
 
21
21
  from PyQt5.QtCore import Qt
22
22
  from PyQt5.QtGui import QImage
@@ -109,6 +109,19 @@ def rasterio_to_qimage(rasterio_src, longest_edge=None):
109
109
  QImage: Scaled image
110
110
  """
111
111
  try:
112
+ # Check if the dataset is closed
113
+ if not rasterio_src or getattr(rasterio_src, 'closed', True):
114
+ # Attempt to reopen the dataset if we can get the path
115
+ if hasattr(rasterio_src, 'name'):
116
+ try:
117
+ rasterio_src = rasterio.open(rasterio_src.name)
118
+ except Exception as reopen_error:
119
+ print(f"Error reopening dataset: {str(reopen_error)}")
120
+ return QImage()
121
+ else:
122
+ print("Cannot read from closed dataset without path information")
123
+ return QImage()
124
+
112
125
  # Get the original size of the image
113
126
  original_width = rasterio_src.width
114
127
  original_height = rasterio_src.height
@@ -190,7 +203,7 @@ def rasterio_to_qimage(rasterio_src, longest_edge=None):
190
203
  # Transpose to height, width, channels format
191
204
  image = np.transpose(image, (1, 2, 0))
192
205
 
193
- # Convert to uint8 if not already
206
+ # Convert to uint8 if image is not already
194
207
  if image.dtype != np.uint8:
195
208
  if image.max() > 0: # Avoid division by zero
196
209
  image = image.astype(float) * (255.0 / image.max())
@@ -222,6 +235,19 @@ def rasterio_to_cropped_image(rasterio_src, window):
222
235
  QImage: Cropped image as a QImage
223
236
  """
224
237
  try:
238
+ # Check if the dataset is closed
239
+ if not rasterio_src or getattr(rasterio_src, 'closed', True):
240
+ # Attempt to reopen the dataset if we can get the path
241
+ if hasattr(rasterio_src, 'name'):
242
+ try:
243
+ rasterio_src = rasterio.open(rasterio_src.name)
244
+ except Exception as reopen_error:
245
+ print(f"Error reopening dataset: {str(reopen_error)}")
246
+ return QImage()
247
+ else:
248
+ print("Cannot read from closed dataset without path information")
249
+ return QImage()
250
+
225
251
  # Check for single-band image with colormap
226
252
  has_colormap = False
227
253
  if rasterio_src.count == 1:
@@ -305,6 +331,19 @@ def rasterio_to_numpy(rasterio_src, longest_edge=None):
305
331
  numpy.ndarray: Image as a numpy array in format (h, w, c) for RGB or (h, w) for grayscale
306
332
  """
307
333
  try:
334
+ # Check if the dataset is closed
335
+ if not rasterio_src or getattr(rasterio_src, 'closed', True):
336
+ # Attempt to reopen the dataset if we can get the path
337
+ if hasattr(rasterio_src, 'name'):
338
+ try:
339
+ rasterio_src = rasterio.open(rasterio_src.name)
340
+ except Exception as reopen_error:
341
+ print(f"Error reopening dataset: {str(reopen_error)}")
342
+ return np.zeros((100, 100, 3), dtype=np.uint8)
343
+ else:
344
+ print("Cannot read from closed dataset without path information")
345
+ return np.zeros((100, 100, 3), dtype=np.uint8)
346
+
308
347
  # Get the original size of the image
309
348
  original_width = rasterio_src.width
310
349
  original_height = rasterio_src.height
@@ -412,6 +451,19 @@ def work_area_to_numpy(rasterio_src, work_area):
412
451
  """
413
452
  if not rasterio_src:
414
453
  return None
454
+
455
+ # Check if the dataset is closed
456
+ if getattr(rasterio_src, 'closed', True):
457
+ # Attempt to reopen the dataset if we can get the path
458
+ if hasattr(rasterio_src, 'name'):
459
+ try:
460
+ rasterio_src = rasterio.open(rasterio_src.name)
461
+ except Exception as reopen_error:
462
+ print(f"Error reopening dataset: {str(reopen_error)}")
463
+ return None
464
+ else:
465
+ print("Cannot read from closed dataset without path information")
466
+ return None
415
467
 
416
468
  # If we got a WorkArea object, use its rect
417
469
  if hasattr(work_area, 'rect'):
@@ -542,50 +594,6 @@ def scale_pixmap(pixmap, max_size):
542
594
  return scaled_pixmap
543
595
 
544
596
 
545
- def attempt_download_asset(app, asset_name, asset_url):
546
- """
547
- Attempt to download an asset from the given URL.
548
-
549
- :param app:
550
- :param asset_name:
551
- :param asset_url:
552
- :return:
553
- """
554
- # Create a progress dialog
555
- progress_dialog = ProgressBar(app, title=f"Downloading {asset_name}")
556
-
557
- try:
558
- # Get the asset name
559
- asset_name = os.path.basename(asset_name)
560
- asset_path = os.path.join(os.getcwd(), asset_name)
561
-
562
- if os.path.exists(asset_path):
563
- return
564
-
565
- # Download the asset
566
- response = requests.get(asset_url, stream=True)
567
- total_size = int(response.headers.get('content-length', 0))
568
- block_size = 1024 # 1 Kibibyte
569
-
570
- # Initialize the progress bar
571
- progress_dialog.start_progress(total_size // block_size)
572
- progress_dialog.show()
573
-
574
- with open(asset_path, 'wb') as f:
575
- for data in response.iter_content(block_size):
576
- if progress_dialog.wasCanceled():
577
- raise Exception("Download canceled by user")
578
- f.write(data)
579
- progress_dialog.update_progress()
580
-
581
- except Exception as e:
582
- QMessageBox.critical(app, "Error", f"Failed to download {asset_name}.\n{e}")
583
-
584
- # Close the progress dialog
585
- progress_dialog.set_value(progress_dialog.max_value)
586
- progress_dialog.close()
587
-
588
-
589
597
  def simplify_polygon(xy_points, simplify_tolerance=0.1):
590
598
  """
591
599
  Filter a list of points to keep only the largest polygon and simplify it.
@@ -666,6 +674,88 @@ def densify_polygon(xy_points):
666
674
  return xy_points.tolist() if isinstance(xy_points, np.ndarray) else xy_points
667
675
 
668
676
 
677
+ def polygonize_mask_with_holes(mask_tensor):
678
+ """
679
+ Converts a boolean mask tensor to an exterior polygon and a list of interior hole polygons.
680
+
681
+ Args:
682
+ mask_tensor (torch.Tensor): A 2D boolean tensor from the prediction results.
683
+
684
+ Returns:
685
+ A tuple containing:
686
+ - exterior (list of tuples): The (x, y) vertices of the outer boundary.
687
+ - holes (list of lists of tuples): A list where each element is a list of (x, y) vertices for a hole.
688
+ """
689
+ # Convert the tensor to a NumPy array format that OpenCV can use
690
+ mask_np = mask_tensor.squeeze().cpu().numpy().astype(np.uint8)
691
+
692
+ # Find all contours and their hierarchy
693
+ # cv2.RETR_CCOMP organizes contours into a two-level hierarchy: external boundaries and holes inside them.
694
+ contours, hierarchy = cv2.findContours(mask_np, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE)
695
+
696
+ if not contours or hierarchy is None:
697
+ return [], []
698
+
699
+ exterior = []
700
+ holes = []
701
+
702
+ # Process the hierarchy to separate the exterior from the holes
703
+ for i, contour in enumerate(contours):
704
+ # An external contour's parent in the hierarchy is -1
705
+ if hierarchy[0][i][3] == -1:
706
+ # Squeeze to convert from [[x, y]] to [x, y] format
707
+ exterior = contour.squeeze(axis=1).tolist()
708
+ else:
709
+ # Any other contour is treated as a hole
710
+ holes.append(contour.squeeze(axis=1).tolist())
711
+
712
+ return exterior, holes
713
+
714
+
715
+ def attempt_download_asset(app, asset_name, asset_url):
716
+ """
717
+ Attempt to download an asset from the given URL.
718
+
719
+ :param app:
720
+ :param asset_name:
721
+ :param asset_url:
722
+ :return:
723
+ """
724
+ # Create a progress dialog
725
+ progress_dialog = ProgressBar(app, title=f"Downloading {asset_name}")
726
+
727
+ try:
728
+ # Get the asset name
729
+ asset_name = os.path.basename(asset_name)
730
+ asset_path = os.path.join(os.getcwd(), asset_name)
731
+
732
+ if os.path.exists(asset_path):
733
+ return
734
+
735
+ # Download the asset
736
+ response = requests.get(asset_url, stream=True)
737
+ total_size = int(response.headers.get('content-length', 0))
738
+ block_size = 1024 # 1 Kibibyte
739
+
740
+ # Initialize the progress bar
741
+ progress_dialog.start_progress(total_size // block_size)
742
+ progress_dialog.show()
743
+
744
+ with open(asset_path, 'wb') as f:
745
+ for data in response.iter_content(block_size):
746
+ if progress_dialog.wasCanceled():
747
+ raise Exception("Download canceled by user")
748
+ f.write(data)
749
+ progress_dialog.update_progress()
750
+
751
+ except Exception as e:
752
+ QMessageBox.critical(app, "Error", f"Failed to download {asset_name}.\n{e}")
753
+
754
+ # Close the progress dialog
755
+ progress_dialog.set_value(progress_dialog.max_value)
756
+ progress_dialog.close()
757
+
758
+
669
759
  def console_user(error_msg, parent=None):
670
760
  """
671
761
  Display an error message to the user via both terminal and GUI dialog.