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.
- 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/QtDataItem.py +52 -22
- coralnet_toolbox/Explorer/QtExplorer.py +293 -1614
- coralnet_toolbox/Explorer/QtSettingsWidgets.py +203 -85
- coralnet_toolbox/Explorer/QtViewers.py +1568 -0
- coralnet_toolbox/Explorer/transformer_models.py +59 -0
- coralnet_toolbox/Explorer/yolo_models.py +112 -0
- 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 +253 -141
- coralnet_toolbox/MachineLearning/VideoInference/QtBase.py +0 -4
- coralnet_toolbox/MachineLearning/VideoInference/YOLO3D/run.py +102 -16
- coralnet_toolbox/QtAnnotationWindow.py +16 -10
- coralnet_toolbox/QtEventFilter.py +11 -0
- coralnet_toolbox/QtImageWindow.py +120 -75
- coralnet_toolbox/QtLabelWindow.py +13 -1
- coralnet_toolbox/QtMainWindow.py +5 -27
- coralnet_toolbox/QtProgressBar.py +52 -27
- coralnet_toolbox/Rasters/RasterTableModel.py +28 -8
- coralnet_toolbox/SAM/QtDeployGenerator.py +1 -4
- coralnet_toolbox/SAM/QtDeployPredictor.py +11 -3
- coralnet_toolbox/SeeAnything/QtDeployGenerator.py +805 -162
- coralnet_toolbox/SeeAnything/QtDeployPredictor.py +130 -151
- coralnet_toolbox/Tools/QtCutSubTool.py +18 -2
- coralnet_toolbox/Tools/QtPolygonTool.py +42 -3
- coralnet_toolbox/Tools/QtRectangleTool.py +30 -0
- 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 +158 -47
- coralnet_toolbox-0.0.75.dist-info/METADATA +378 -0
- {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.75.dist-info}/RECORD +49 -44
- coralnet_toolbox-0.0.73.dist-info/METADATA +0 -341
- {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.75.dist-info}/WHEEL +0 -0
- {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.75.dist-info}/entry_points.txt +0 -0
- {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.75.dist-info}/licenses/LICENSE.txt +0 -0
- {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
|
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
|
373
|
+
# Get the top confidence prediction's mask tensor
|
373
374
|
top1_index = np.argmax(results.boxes.conf)
|
374
|
-
|
375
|
+
mask_tensor = results[top1_index].masks.data
|
375
376
|
|
376
|
-
#
|
377
|
-
|
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
|
-
#
|
382
|
-
|
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(
|
384
|
+
if len(exterior_coords) < 3:
|
386
385
|
QApplication.restoreOverrideCursor()
|
387
386
|
return
|
388
387
|
|
389
|
-
#
|
388
|
+
# --- Process and Clean the Polygon Points ---
|
390
389
|
working_area_top_left = self.working_area.rect.topLeft()
|
391
|
-
|
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
|
-
#
|
395
|
-
|
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
|
-
#
|
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
|
-
|
401
|
-
self.annotation_window.selected_label.
|
402
|
-
self.annotation_window.selected_label.
|
403
|
-
self.annotation_window.
|
404
|
-
self.annotation_window.
|
405
|
-
self.
|
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.
|
615
|
-
self.
|
616
|
-
self.
|
617
|
-
self.
|
618
|
-
self.
|
619
|
-
self.
|
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
|
-
|
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
|
-
#
|
735
|
-
|
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
|
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
|
-
|
745
|
-
|
746
|
-
#
|
747
|
-
|
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
|
-
|
761
|
-
self.annotation_window.selected_label.
|
762
|
-
self.annotation_window.selected_label.
|
763
|
-
self.annotation_window.
|
764
|
-
self.annotation_window.
|
765
|
-
self.
|
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
|
-
|
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.
|
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
|
-
|
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
|
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'):
|
@@ -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.
|