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.
- coralnet_toolbox/Annotations/QtAnnotation.py +28 -69
- coralnet_toolbox/Annotations/QtMaskAnnotation.py +408 -0
- coralnet_toolbox/Annotations/QtMultiPolygonAnnotation.py +72 -56
- coralnet_toolbox/Annotations/QtPatchAnnotation.py +165 -216
- coralnet_toolbox/Annotations/QtPolygonAnnotation.py +497 -353
- coralnet_toolbox/Annotations/QtRectangleAnnotation.py +126 -116
- coralnet_toolbox/CoralNet/QtDownload.py +2 -1
- coralnet_toolbox/Explorer/QtExplorer.py +16 -14
- coralnet_toolbox/Explorer/QtSettingsWidgets.py +114 -82
- coralnet_toolbox/IO/QtExportTagLabAnnotations.py +30 -10
- coralnet_toolbox/IO/QtImportTagLabAnnotations.py +21 -15
- coralnet_toolbox/IO/QtOpenProject.py +46 -78
- coralnet_toolbox/IO/QtSaveProject.py +18 -43
- coralnet_toolbox/MachineLearning/ExportDataset/QtBase.py +1 -1
- coralnet_toolbox/MachineLearning/ImportDataset/QtBase.py +42 -22
- coralnet_toolbox/MachineLearning/VideoInference/QtBase.py +0 -4
- coralnet_toolbox/QtEventFilter.py +11 -0
- coralnet_toolbox/QtImageWindow.py +117 -68
- coralnet_toolbox/QtLabelWindow.py +13 -1
- coralnet_toolbox/QtMainWindow.py +5 -27
- coralnet_toolbox/QtProgressBar.py +52 -27
- coralnet_toolbox/Rasters/RasterTableModel.py +8 -8
- coralnet_toolbox/SAM/QtDeployPredictor.py +10 -0
- coralnet_toolbox/SeeAnything/QtDeployGenerator.py +779 -161
- coralnet_toolbox/SeeAnything/QtDeployPredictor.py +86 -149
- coralnet_toolbox/Tools/QtCutSubTool.py +18 -2
- coralnet_toolbox/Tools/QtResizeSubTool.py +19 -2
- coralnet_toolbox/Tools/QtSAMTool.py +72 -50
- coralnet_toolbox/Tools/QtSeeAnythingTool.py +8 -5
- coralnet_toolbox/Tools/QtSelectTool.py +27 -3
- coralnet_toolbox/Tools/QtSubtractSubTool.py +66 -0
- coralnet_toolbox/Tools/__init__.py +2 -0
- coralnet_toolbox/__init__.py +1 -1
- coralnet_toolbox/utilities.py +137 -47
- coralnet_toolbox-0.0.74.dist-info/METADATA +375 -0
- {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.74.dist-info}/RECORD +40 -38
- coralnet_toolbox-0.0.73.dist-info/METADATA +0 -341
- {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.74.dist-info}/WHEEL +0 -0
- {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.74.dist-info}/entry_points.txt +0 -0
- {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.74.dist-info}/licenses/LICENSE.txt +0 -0
- {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
|
179
|
-
if event.key() == Qt.Key_X
|
180
|
-
|
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
|
]
|
coralnet_toolbox/__init__.py
CHANGED
coralnet_toolbox/utilities.py
CHANGED
@@ -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.
|
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.
|