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
@@ -1,7 +1,7 @@
1
1
  import warnings
2
2
  import numpy as np
3
3
 
4
- from PyQt5.QtCore import Qt, QPointF, QRectF, QTimer
4
+ from PyQt5.QtCore import Qt, QPointF, QRectF
5
5
  from PyQt5.QtGui import QMouseEvent, QKeyEvent, QPen, QColor, QBrush, QPainterPath
6
6
  from PyQt5.QtWidgets import QMessageBox, QGraphicsEllipseItem, QGraphicsRectItem, QGraphicsPathItem, QApplication
7
7
 
@@ -12,6 +12,7 @@ from coralnet_toolbox.QtWorkArea import WorkArea
12
12
 
13
13
  from coralnet_toolbox.utilities import pixmap_to_numpy
14
14
  from coralnet_toolbox.utilities import simplify_polygon
15
+ from coralnet_toolbox.utilities import polygonize_mask_with_holes
15
16
 
16
17
  warnings.filterwarnings("ignore", category=DeprecationWarning)
17
18
 
@@ -369,40 +370,47 @@ class SAMTool(Tool):
369
370
  QApplication.restoreOverrideCursor()
370
371
  return
371
372
 
372
- # Get the points of the top1 mask
373
+ # Get the top confidence prediction's mask tensor
373
374
  top1_index = np.argmax(results.boxes.conf)
374
- predictions = results[top1_index].masks.xy[0]
375
+ mask_tensor = results[top1_index].masks.data
375
376
 
376
- # Safety check: make sure we have predicted points
377
- if len(predictions) == 0:
378
- QApplication.restoreOverrideCursor()
379
- return
377
+ # Check if holes are allowed from the SAM dialog
378
+ allow_holes = self.sam_dialog.get_allow_holes()
380
379
 
381
- # Clean the polygon using Ramer-Douglas-Peucker algorithm
382
- predictions = simplify_polygon(predictions, 0.1)
380
+ # Polygonize the mask to get the exterior and holes
381
+ exterior_coords, holes_coords_list = polygonize_mask_with_holes(mask_tensor)
383
382
 
384
383
  # Safety check: need at least 3 points for a valid polygon
385
- if len(predictions) < 3:
384
+ if len(exterior_coords) < 3:
386
385
  QApplication.restoreOverrideCursor()
387
386
  return
388
387
 
389
- # Move the points back to the original image space
388
+ # --- Process and Clean the Polygon Points ---
390
389
  working_area_top_left = self.working_area.rect.topLeft()
391
- points = [(point[0] + working_area_top_left.x(),
392
- point[1] + working_area_top_left.y()) for point in predictions]
390
+ offset_x, offset_y = working_area_top_left.x(), working_area_top_left.y()
393
391
 
394
- # Convert to QPointF for graphics
395
- self.points = [QPointF(*point) for point in points]
392
+ # Simplify, offset, and convert the exterior points
393
+ simplified_exterior = simplify_polygon(exterior_coords, 0.1)
394
+ self.points = [QPointF(p[0] + offset_x, p[1] + offset_y) for p in simplified_exterior]
396
395
 
397
- # Create the temporary annotation
396
+ # Simplify, offset, and convert each hole only if allowed
397
+ final_holes = []
398
+ if allow_holes:
399
+ for hole_coords in holes_coords_list:
400
+ if len(hole_coords) >= 3: # Ensure holes are also valid polygons
401
+ simplified_hole = simplify_polygon(hole_coords, 0.1)
402
+ final_holes.append([QPointF(p[0] + offset_x, p[1] + offset_y) for p in simplified_hole])
403
+
404
+ # Create the temporary annotation, now with holes (or not)
398
405
  self.temp_annotation = PolygonAnnotation(
399
- self.points,
400
- self.annotation_window.selected_label.short_label_code,
401
- self.annotation_window.selected_label.long_label_code,
402
- self.annotation_window.selected_label.color,
403
- self.annotation_window.current_image_path,
404
- self.annotation_window.selected_label.id,
405
- self.main_window.label_window.active_label.transparency
406
+ points=self.points,
407
+ holes=final_holes,
408
+ short_label_code=self.annotation_window.selected_label.short_label_code,
409
+ long_label_code=self.annotation_window.selected_label.long_label_code,
410
+ color=self.annotation_window.selected_label.color,
411
+ image_path=self.annotation_window.current_image_path,
412
+ label_id=self.annotation_window.selected_label.id,
413
+ transparency=self.main_window.label_window.active_label.transparency
406
414
  )
407
415
 
408
416
  # Create the graphics item for the temporary annotation
@@ -611,12 +619,13 @@ class SAMTool(Tool):
611
619
  # Use existing temporary annotation
612
620
  final_annotation = PolygonAnnotation(
613
621
  self.points,
614
- self.annotation_window.selected_label.short_label_code,
615
- self.annotation_window.selected_label.long_label_code,
616
- self.annotation_window.selected_label.color,
617
- self.annotation_window.current_image_path,
618
- self.annotation_window.selected_label.id,
619
- self.main_window.label_window.active_label.transparency
622
+ self.temp_annotation.label.short_label_code,
623
+ self.temp_annotation.label.long_label_code,
624
+ self.temp_annotation.label.color,
625
+ self.temp_annotation.image_path,
626
+ self.temp_annotation.label.id,
627
+ self.temp_annotation.label.transparency,
628
+ holes=self.temp_annotation.holes
620
629
  )
621
630
 
622
631
  # Copy confidence data
@@ -637,7 +646,7 @@ class SAMTool(Tool):
637
646
  final_annotation = self.create_annotation(True)
638
647
  if final_annotation:
639
648
  self.annotation_window.add_annotation_from_tool(final_annotation)
640
- self.clear_prompt_graphics()
649
+ self.clear_prompt_graphics()
641
650
  # If no active prompts, cancel the working area
642
651
  else:
643
652
  self.cancel_working_area()
@@ -727,24 +736,36 @@ class SAMTool(Tool):
727
736
  QApplication.restoreOverrideCursor()
728
737
  return None
729
738
 
730
- # Get the top confidence prediction
739
+ # Get the top confidence prediction's mask tensor
731
740
  top1_index = np.argmax(results.boxes.conf)
732
- predictions = results[top1_index].masks.xy[0]
741
+ mask_tensor = results[top1_index].masks.data
742
+
743
+ # Check if holes are allowed from the SAM dialog
744
+ allow_holes = self.sam_dialog.get_allow_holes()
733
745
 
734
- # Safety check for predictions
735
- if len(predictions) == 0:
746
+ # Polygonize the mask using the new method to get the exterior and holes
747
+ exterior_coords, holes_coords_list = polygonize_mask_with_holes(mask_tensor)
748
+
749
+ # Safety check for an empty result
750
+ if not exterior_coords:
736
751
  QApplication.restoreOverrideCursor()
737
752
  return None
738
753
 
739
- # Clean polygon points
740
- predictions = simplify_polygon(predictions, 0.1)
741
-
742
- # Move points back to original image space
754
+ # --- Process and Clean the Polygon Points ---
743
755
  working_area_top_left = self.working_area.rect.topLeft()
744
- points = [(point[0] + working_area_top_left.x(),
745
- point[1] + working_area_top_left.y()) for point in predictions]
746
- # Convert to QPointF for graphics
747
- self.points = [QPointF(*point) for point in points]
756
+ offset_x, offset_y = working_area_top_left.x(), working_area_top_left.y()
757
+
758
+ # Simplify, offset, and convert the exterior points
759
+ simplified_exterior = simplify_polygon(exterior_coords, 0.1)
760
+ self.points = [QPointF(p[0] + offset_x, p[1] + offset_y) for p in simplified_exterior]
761
+
762
+ # Simplify, offset, and convert each hole only if allowed
763
+ final_holes = []
764
+ if allow_holes:
765
+ for hole_coords in holes_coords_list:
766
+ if len(hole_coords) >= 3:
767
+ simplified_hole = simplify_polygon(hole_coords, 0.1)
768
+ final_holes.append([QPointF(p[0] + offset_x, p[1] + offset_y) for p in simplified_hole])
748
769
 
749
770
  # Require at least 3 points for valid polygon
750
771
  if len(self.points) < 3:
@@ -754,15 +775,16 @@ class SAMTool(Tool):
754
775
  # Get confidence score
755
776
  confidence = results.boxes.conf[top1_index].item()
756
777
 
757
- # Create final annotation
778
+ # Create final annotation, now passing the holes argument
758
779
  annotation = PolygonAnnotation(
759
- self.points,
760
- self.annotation_window.selected_label.short_label_code,
761
- self.annotation_window.selected_label.long_label_code,
762
- self.annotation_window.selected_label.color,
763
- self.annotation_window.current_image_path,
764
- self.annotation_window.selected_label.id,
765
- self.main_window.label_window.active_label.transparency
780
+ points=self.points,
781
+ holes=final_holes,
782
+ short_label_code=self.annotation_window.selected_label.short_label_code,
783
+ long_label_code=self.annotation_window.selected_label.long_label_code,
784
+ color=self.annotation_window.selected_label.color,
785
+ image_path=self.annotation_window.current_image_path,
786
+ label_id=self.annotation_window.selected_label.id,
787
+ transparency=self.main_window.label_window.active_label.transparency
766
788
  )
767
789
 
768
790
  # Update confidence
@@ -173,6 +173,7 @@ class SeeAnythingTool(Tool):
173
173
 
174
174
  # Set the image in the SeeAnything dialog
175
175
  self.see_anything_dialog.set_image(self.work_area_image, self.image_path)
176
+ # self.see_anything_dialog.reload_model()
176
177
 
177
178
  self.annotation_window.setCursor(Qt.CrossCursor)
178
179
  self.annotation_window.scene.update()
@@ -552,9 +553,9 @@ class SeeAnythingTool(Tool):
552
553
  # Move the points back to the original image space
553
554
  working_area_top_left = self.working_area.rect.topLeft()
554
555
 
555
- task = self.see_anything_dialog.task_dropdown.currentText()
556
556
  masks = None
557
- if task == 'segment':
557
+ # Create masks from the rectangles (these are not polygons)
558
+ if self.see_anything_dialog.task_dropdown.currentText() == 'segment':
558
559
  masks = []
559
560
  for r in self.rectangles:
560
561
  x1, y1, x2, y2 = r
@@ -587,8 +588,8 @@ class SeeAnythingTool(Tool):
587
588
  # Clear previous annotations if any
588
589
  self.clear_annotations()
589
590
 
590
- # Process results based on the task type
591
- if self.see_anything_dialog.task == "segment":
591
+ # Process results based on the task type (creates polygons or rectangle annotations)
592
+ if self.see_anything_dialog.task_dropdown.currentText() == "segment":
592
593
  if self.results.masks:
593
594
  for i, polygon in enumerate(self.results.masks.xyn):
594
595
  confidence = self.results.boxes.conf[i].item()
@@ -624,7 +625,9 @@ class SeeAnythingTool(Tool):
624
625
  box_abs_work_area = box_norm.detach().cpu().numpy() * np.array(
625
626
  [self.work_area_image.shape[1], self.work_area_image.shape[0],
626
627
  self.work_area_image.shape[1], self.work_area_image.shape[0]])
627
- box_area = (box_abs_work_area[2] - box_abs_work_area[0]) * (box_abs_work_area[3] - box_abs_work_area[1])
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])
628
631
 
629
632
  # Area filtering
630
633
  min_area = self.main_window.get_area_thresh_min() * image_area
@@ -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.75"
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'):
@@ -519,6 +571,27 @@ def pixmap_to_numpy(pixmap):
519
571
  return numpy_array
520
572
 
521
573
 
574
+ def pixmap_to_pil(pixmap):
575
+ """
576
+ Convert a QPixmap to a PIL Image.
577
+
578
+ :param pixmap: QPixmap to convert
579
+ :return: PIL Image in RGB format
580
+ """
581
+ from PIL import Image
582
+
583
+ # Convert pixmap to numpy array first
584
+ image_np = pixmap_to_numpy(pixmap)
585
+
586
+ # Convert numpy array to PIL Image
587
+ if len(image_np.shape) == 2: # Grayscale
588
+ pil_image = Image.fromarray(image_np, mode='L').convert('RGB')
589
+ else: # RGB
590
+ pil_image = Image.fromarray(image_np, mode='RGB')
591
+
592
+ return pil_image
593
+
594
+
522
595
  def scale_pixmap(pixmap, max_size):
523
596
  """Scale pixmap and graphic if they exceed max dimension while preserving aspect ratio"""
524
597
  width = pixmap.width()
@@ -542,50 +615,6 @@ def scale_pixmap(pixmap, max_size):
542
615
  return scaled_pixmap
543
616
 
544
617
 
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
618
  def simplify_polygon(xy_points, simplify_tolerance=0.1):
590
619
  """
591
620
  Filter a list of points to keep only the largest polygon and simplify it.
@@ -666,6 +695,88 @@ def densify_polygon(xy_points):
666
695
  return xy_points.tolist() if isinstance(xy_points, np.ndarray) else xy_points
667
696
 
668
697
 
698
+ def polygonize_mask_with_holes(mask_tensor):
699
+ """
700
+ Converts a boolean mask tensor to an exterior polygon and a list of interior hole polygons.
701
+
702
+ Args:
703
+ mask_tensor (torch.Tensor): A 2D boolean tensor from the prediction results.
704
+
705
+ Returns:
706
+ A tuple containing:
707
+ - exterior (list of tuples): The (x, y) vertices of the outer boundary.
708
+ - holes (list of lists of tuples): A list where each element is a list of (x, y) vertices for a hole.
709
+ """
710
+ # Convert the tensor to a NumPy array format that OpenCV can use
711
+ mask_np = mask_tensor.squeeze().cpu().numpy().astype(np.uint8)
712
+
713
+ # Find all contours and their hierarchy
714
+ # cv2.RETR_CCOMP organizes contours into a two-level hierarchy: external boundaries and holes inside them.
715
+ contours, hierarchy = cv2.findContours(mask_np, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE)
716
+
717
+ if not contours or hierarchy is None:
718
+ return [], []
719
+
720
+ exterior = []
721
+ holes = []
722
+
723
+ # Process the hierarchy to separate the exterior from the holes
724
+ for i, contour in enumerate(contours):
725
+ # An external contour's parent in the hierarchy is -1
726
+ if hierarchy[0][i][3] == -1:
727
+ # Squeeze to convert from [[x, y]] to [x, y] format
728
+ exterior = contour.squeeze(axis=1).tolist()
729
+ else:
730
+ # Any other contour is treated as a hole
731
+ holes.append(contour.squeeze(axis=1).tolist())
732
+
733
+ return exterior, holes
734
+
735
+
736
+ def attempt_download_asset(app, asset_name, asset_url):
737
+ """
738
+ Attempt to download an asset from the given URL.
739
+
740
+ :param app:
741
+ :param asset_name:
742
+ :param asset_url:
743
+ :return:
744
+ """
745
+ # Create a progress dialog
746
+ progress_dialog = ProgressBar(app, title=f"Downloading {asset_name}")
747
+
748
+ try:
749
+ # Get the asset name
750
+ asset_name = os.path.basename(asset_name)
751
+ asset_path = os.path.join(os.getcwd(), asset_name)
752
+
753
+ if os.path.exists(asset_path):
754
+ return
755
+
756
+ # Download the asset
757
+ response = requests.get(asset_url, stream=True)
758
+ total_size = int(response.headers.get('content-length', 0))
759
+ block_size = 1024 # 1 Kibibyte
760
+
761
+ # Initialize the progress bar
762
+ progress_dialog.start_progress(total_size // block_size)
763
+ progress_dialog.show()
764
+
765
+ with open(asset_path, 'wb') as f:
766
+ for data in response.iter_content(block_size):
767
+ if progress_dialog.wasCanceled():
768
+ raise Exception("Download canceled by user")
769
+ f.write(data)
770
+ progress_dialog.update_progress()
771
+
772
+ except Exception as e:
773
+ QMessageBox.critical(app, "Error", f"Failed to download {asset_name}.\n{e}")
774
+
775
+ # Close the progress dialog
776
+ progress_dialog.set_value(progress_dialog.max_value)
777
+ progress_dialog.close()
778
+
779
+
669
780
  def console_user(error_msg, parent=None):
670
781
  """
671
782
  Display an error message to the user via both terminal and GUI dialog.