lazylabel-gui 1.1.6__py3-none-any.whl → 1.1.8__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.
- lazylabel/core/file_manager.py +44 -1
- lazylabel/core/model_manager.py +12 -0
- lazylabel/ui/control_panel.py +676 -98
- lazylabel/ui/main_window.py +1004 -17
- lazylabel/ui/widgets/__init__.py +13 -2
- lazylabel/ui/widgets/adjustments_widget.py +23 -40
- lazylabel/ui/widgets/border_crop_widget.py +210 -0
- lazylabel/ui/widgets/channel_threshold_widget.py +500 -0
- lazylabel/ui/widgets/fragment_threshold_widget.py +97 -0
- lazylabel/ui/widgets/model_selection_widget.py +26 -0
- lazylabel/ui/widgets/settings_widget.py +1 -1
- lazylabel_gui-1.1.8.dist-info/METADATA +240 -0
- {lazylabel_gui-1.1.6.dist-info → lazylabel_gui-1.1.8.dist-info}/RECORD +17 -14
- lazylabel_gui-1.1.6.dist-info/METADATA +0 -244
- {lazylabel_gui-1.1.6.dist-info → lazylabel_gui-1.1.8.dist-info}/WHEEL +0 -0
- {lazylabel_gui-1.1.6.dist-info → lazylabel_gui-1.1.8.dist-info}/entry_points.txt +0 -0
- {lazylabel_gui-1.1.6.dist-info → lazylabel_gui-1.1.8.dist-info}/licenses/LICENSE +0 -0
- {lazylabel_gui-1.1.6.dist-info → lazylabel_gui-1.1.8.dist-info}/top_level.txt +0 -0
lazylabel/ui/main_window.py
CHANGED
@@ -1,14 +1,17 @@
|
|
1
1
|
"""Main application window."""
|
2
2
|
|
3
|
+
import hashlib
|
3
4
|
import os
|
5
|
+
from pathlib import Path
|
4
6
|
|
5
7
|
import cv2
|
6
8
|
import numpy as np
|
7
|
-
from PyQt6.QtCore import QModelIndex, QPointF, QRectF, Qt, QTimer, pyqtSignal
|
9
|
+
from PyQt6.QtCore import QModelIndex, QPointF, QRectF, Qt, QThread, QTimer, pyqtSignal
|
8
10
|
from PyQt6.QtGui import (
|
9
11
|
QBrush,
|
10
12
|
QColor,
|
11
13
|
QIcon,
|
14
|
+
QImage,
|
12
15
|
QKeySequence,
|
13
16
|
QPen,
|
14
17
|
QPixmap,
|
@@ -46,6 +49,129 @@ from .right_panel import RightPanel
|
|
46
49
|
from .widgets import StatusBar
|
47
50
|
|
48
51
|
|
52
|
+
class SAMUpdateWorker(QThread):
|
53
|
+
"""Worker thread for updating SAM model in background."""
|
54
|
+
|
55
|
+
finished = pyqtSignal()
|
56
|
+
error = pyqtSignal(str)
|
57
|
+
|
58
|
+
def __init__(
|
59
|
+
self,
|
60
|
+
model_manager,
|
61
|
+
image_path,
|
62
|
+
operate_on_view,
|
63
|
+
current_image=None,
|
64
|
+
parent=None,
|
65
|
+
):
|
66
|
+
super().__init__(parent)
|
67
|
+
self.model_manager = model_manager
|
68
|
+
self.image_path = image_path
|
69
|
+
self.operate_on_view = operate_on_view
|
70
|
+
self.current_image = current_image # Numpy array of current modified image
|
71
|
+
self._should_stop = False
|
72
|
+
self.scale_factor = 1.0 # Track scaling factor for coordinate transformation
|
73
|
+
|
74
|
+
def stop(self):
|
75
|
+
"""Request the worker to stop."""
|
76
|
+
self._should_stop = True
|
77
|
+
|
78
|
+
def get_scale_factor(self):
|
79
|
+
"""Get the scale factor used for image resizing."""
|
80
|
+
return self.scale_factor
|
81
|
+
|
82
|
+
def run(self):
|
83
|
+
"""Run SAM update in background thread."""
|
84
|
+
try:
|
85
|
+
if self._should_stop:
|
86
|
+
return
|
87
|
+
|
88
|
+
if self.operate_on_view and self.current_image is not None:
|
89
|
+
# Use the provided modified image
|
90
|
+
if self._should_stop:
|
91
|
+
return
|
92
|
+
|
93
|
+
# Optimize image size for faster SAM processing
|
94
|
+
image = self.current_image
|
95
|
+
original_height, original_width = image.shape[:2]
|
96
|
+
max_size = 1024
|
97
|
+
|
98
|
+
if original_height > max_size or original_width > max_size:
|
99
|
+
# Calculate scaling factor
|
100
|
+
self.scale_factor = min(
|
101
|
+
max_size / original_width, max_size / original_height
|
102
|
+
)
|
103
|
+
new_width = int(original_width * self.scale_factor)
|
104
|
+
new_height = int(original_height * self.scale_factor)
|
105
|
+
|
106
|
+
# Resize using OpenCV for speed
|
107
|
+
image = cv2.resize(
|
108
|
+
image, (new_width, new_height), interpolation=cv2.INTER_AREA
|
109
|
+
)
|
110
|
+
else:
|
111
|
+
self.scale_factor = 1.0
|
112
|
+
|
113
|
+
if self._should_stop:
|
114
|
+
return
|
115
|
+
|
116
|
+
# Set image from numpy array (FIXED: use resized image, not original)
|
117
|
+
self.model_manager.set_image_from_array(image)
|
118
|
+
else:
|
119
|
+
# Load original image
|
120
|
+
pixmap = QPixmap(self.image_path)
|
121
|
+
if pixmap.isNull():
|
122
|
+
self.error.emit("Failed to load image")
|
123
|
+
return
|
124
|
+
|
125
|
+
if self._should_stop:
|
126
|
+
return
|
127
|
+
|
128
|
+
original_width = pixmap.width()
|
129
|
+
original_height = pixmap.height()
|
130
|
+
|
131
|
+
# Optimize image size for faster SAM processing
|
132
|
+
max_size = 1024
|
133
|
+
if original_width > max_size or original_height > max_size:
|
134
|
+
# Calculate scaling factor
|
135
|
+
self.scale_factor = min(
|
136
|
+
max_size / original_width, max_size / original_height
|
137
|
+
)
|
138
|
+
|
139
|
+
# Scale down while maintaining aspect ratio
|
140
|
+
scaled_pixmap = pixmap.scaled(
|
141
|
+
max_size,
|
142
|
+
max_size,
|
143
|
+
Qt.AspectRatioMode.KeepAspectRatio,
|
144
|
+
Qt.TransformationMode.SmoothTransformation,
|
145
|
+
)
|
146
|
+
|
147
|
+
# Convert to numpy array for SAM
|
148
|
+
qimage = scaled_pixmap.toImage()
|
149
|
+
width = qimage.width()
|
150
|
+
height = qimage.height()
|
151
|
+
ptr = qimage.bits()
|
152
|
+
ptr.setsize(height * width * 4)
|
153
|
+
arr = np.array(ptr).reshape(height, width, 4)
|
154
|
+
# Convert RGBA to RGB
|
155
|
+
image_array = arr[:, :, :3]
|
156
|
+
|
157
|
+
if self._should_stop:
|
158
|
+
return
|
159
|
+
|
160
|
+
# FIXED: Use the resized image array, not original path
|
161
|
+
self.model_manager.set_image_from_array(image_array)
|
162
|
+
else:
|
163
|
+
self.scale_factor = 1.0
|
164
|
+
# For images that don't need resizing, use original path
|
165
|
+
self.model_manager.set_image_from_path(self.image_path)
|
166
|
+
|
167
|
+
if not self._should_stop:
|
168
|
+
self.finished.emit()
|
169
|
+
|
170
|
+
except Exception as e:
|
171
|
+
if not self._should_stop:
|
172
|
+
self.error.emit(str(e))
|
173
|
+
|
174
|
+
|
49
175
|
class PanelPopoutWindow(QDialog):
|
50
176
|
"""Pop-out window for draggable panels."""
|
51
177
|
|
@@ -132,6 +258,40 @@ class MainWindow(QMainWindow):
|
|
132
258
|
# Update state flags to prevent recursion
|
133
259
|
self._updating_lists = False
|
134
260
|
|
261
|
+
# Crop feature state
|
262
|
+
self.crop_mode = False
|
263
|
+
self.crop_rect_item = None
|
264
|
+
self.crop_start_pos = None
|
265
|
+
self.crop_coords_by_size = {} # Dictionary to store crop coordinates by image size
|
266
|
+
self.current_crop_coords = None # Current crop coordinates (x1, y1, x2, y2)
|
267
|
+
self.crop_visual_overlays = [] # Visual overlays showing crop areas
|
268
|
+
self.crop_hover_overlays = [] # Hover overlays for cropped areas
|
269
|
+
self.crop_hover_effect_items = [] # Hover effect items
|
270
|
+
self.is_hovering_crop = False # Track if mouse is hovering over crop area
|
271
|
+
|
272
|
+
# Channel threshold widget cache
|
273
|
+
self._cached_original_image = None # Cache for performance optimization
|
274
|
+
|
275
|
+
# SAM model update debouncing for "operate on view" mode
|
276
|
+
self.sam_update_timer = QTimer()
|
277
|
+
self.sam_update_timer.setSingleShot(True) # Only fire once
|
278
|
+
self.sam_update_timer.timeout.connect(self._update_sam_model_image_debounced)
|
279
|
+
self.sam_update_delay = 500 # 500ms delay for regular value changes
|
280
|
+
self.drag_finish_delay = 150 # 150ms delay when drag finishes (more responsive)
|
281
|
+
self.any_slider_dragging = False # Track if any slider is being dragged
|
282
|
+
self.sam_is_dirty = False # Track if SAM needs updating
|
283
|
+
self.sam_is_updating = False # Track if SAM is currently updating
|
284
|
+
|
285
|
+
# SAM update threading for better responsiveness
|
286
|
+
self.sam_worker_thread = None
|
287
|
+
self.sam_scale_factor = (
|
288
|
+
1.0 # Track current SAM scale factor for coordinate transformation
|
289
|
+
)
|
290
|
+
|
291
|
+
# Smart caching for SAM embeddings to avoid redundant processing
|
292
|
+
self.sam_embedding_cache = {} # Cache SAM embeddings by content hash
|
293
|
+
self.current_sam_hash = None # Hash of currently loaded SAM image
|
294
|
+
|
135
295
|
self._setup_ui()
|
136
296
|
self._setup_model()
|
137
297
|
self._setup_connections()
|
@@ -242,6 +402,7 @@ class MainWindow(QMainWindow):
|
|
242
402
|
self.control_panel.polygon_mode_requested.connect(self.set_polygon_mode)
|
243
403
|
self.control_panel.bbox_mode_requested.connect(self.set_bbox_mode)
|
244
404
|
self.control_panel.selection_mode_requested.connect(self.toggle_selection_mode)
|
405
|
+
self.control_panel.edit_mode_requested.connect(self._handle_edit_mode_request)
|
245
406
|
self.control_panel.clear_points_requested.connect(self.clear_all_points)
|
246
407
|
self.control_panel.fit_view_requested.connect(self.viewer.fitInView)
|
247
408
|
self.control_panel.hotkeys_requested.connect(self._show_hotkey_dialog)
|
@@ -271,6 +432,16 @@ class MainWindow(QMainWindow):
|
|
271
432
|
self._handle_image_adjustment_changed
|
272
433
|
)
|
273
434
|
|
435
|
+
# Border crop connections
|
436
|
+
self.control_panel.crop_draw_requested.connect(self._start_crop_drawing)
|
437
|
+
self.control_panel.crop_clear_requested.connect(self._clear_crop)
|
438
|
+
self.control_panel.crop_applied.connect(self._apply_crop_coordinates)
|
439
|
+
|
440
|
+
# Channel threshold connections
|
441
|
+
self.control_panel.channel_threshold_changed.connect(
|
442
|
+
self._handle_channel_threshold_changed
|
443
|
+
)
|
444
|
+
|
274
445
|
# Right panel connections
|
275
446
|
self.right_panel.open_folder_requested.connect(self._open_folder_dialog)
|
276
447
|
self.right_panel.image_selected.connect(self._load_selected_image)
|
@@ -316,7 +487,7 @@ class MainWindow(QMainWindow):
|
|
316
487
|
"bbox_mode": self.set_bbox_mode,
|
317
488
|
"selection_mode": self.toggle_selection_mode,
|
318
489
|
"pan_mode": self.toggle_pan_mode,
|
319
|
-
"edit_mode": self.
|
490
|
+
"edit_mode": self._handle_edit_mode_request,
|
320
491
|
"clear_points": self.clear_all_points,
|
321
492
|
"escape": self._handle_escape_press,
|
322
493
|
"delete_segments": self._delete_selected_segments,
|
@@ -383,11 +554,13 @@ class MainWindow(QMainWindow):
|
|
383
554
|
|
384
555
|
# Mode management methods
|
385
556
|
def set_sam_mode(self):
|
386
|
-
"""Set SAM points
|
557
|
+
"""Set mode to SAM points."""
|
387
558
|
if not self.model_manager.is_model_available():
|
388
559
|
logger.warning("Cannot enter SAM mode: No model available")
|
389
560
|
return
|
390
561
|
self._set_mode("sam_points")
|
562
|
+
# Ensure SAM model is updated when entering SAM mode (lazy update)
|
563
|
+
self._ensure_sam_updated()
|
391
564
|
|
392
565
|
def set_polygon_mode(self):
|
393
566
|
"""Set polygon drawing mode."""
|
@@ -409,6 +582,32 @@ class MainWindow(QMainWindow):
|
|
409
582
|
"""Toggle edit mode."""
|
410
583
|
self._toggle_mode("edit")
|
411
584
|
|
585
|
+
def _handle_edit_mode_request(self):
|
586
|
+
"""Handle edit mode request with validation."""
|
587
|
+
# Check if there are any polygon segments to edit
|
588
|
+
polygon_segments = [
|
589
|
+
seg for seg in self.segment_manager.segments if seg.get("type") == "Polygon"
|
590
|
+
]
|
591
|
+
|
592
|
+
if not polygon_segments:
|
593
|
+
self._show_error_notification("No polygons selected!")
|
594
|
+
return
|
595
|
+
|
596
|
+
# Check if any polygons are actually selected
|
597
|
+
selected_indices = self.right_panel.get_selected_segment_indices()
|
598
|
+
selected_polygons = [
|
599
|
+
i
|
600
|
+
for i in selected_indices
|
601
|
+
if self.segment_manager.segments[i].get("type") == "Polygon"
|
602
|
+
]
|
603
|
+
|
604
|
+
if not selected_polygons:
|
605
|
+
self._show_error_notification("No polygons selected!")
|
606
|
+
return
|
607
|
+
|
608
|
+
# Enter edit mode if validation passes
|
609
|
+
self.toggle_edit_mode()
|
610
|
+
|
412
611
|
def _set_mode(self, mode_name, is_toggle=False):
|
413
612
|
"""Set the current mode."""
|
414
613
|
if not is_toggle and self.mode not in ["selection", "edit"]:
|
@@ -587,7 +786,7 @@ class MainWindow(QMainWindow):
|
|
587
786
|
self.viewer.setFocus()
|
588
787
|
|
589
788
|
def _load_selected_image(self, index):
|
590
|
-
"""Load the selected image."""
|
789
|
+
"""Load the selected image. Auto-saves previous work if enabled."""
|
591
790
|
|
592
791
|
if not index.isValid() or not self.file_model.isDir(index.parent()):
|
593
792
|
return
|
@@ -599,6 +798,12 @@ class MainWindow(QMainWindow):
|
|
599
798
|
if path == self.current_image_path: # Only reset if loading a new image
|
600
799
|
return
|
601
800
|
|
801
|
+
# Auto-save if enabled and we have a current image (not the first load)
|
802
|
+
if self.current_image_path and self.control_panel.get_settings().get(
|
803
|
+
"auto_save", True
|
804
|
+
):
|
805
|
+
self._save_output_to_npz()
|
806
|
+
|
602
807
|
self.current_image_path = path
|
603
808
|
pixmap = QPixmap(self.current_image_path)
|
604
809
|
if not pixmap.isNull():
|
@@ -614,6 +819,28 @@ class MainWindow(QMainWindow):
|
|
614
819
|
self._update_all_lists()
|
615
820
|
self.viewer.setFocus()
|
616
821
|
|
822
|
+
if self.model_manager.is_model_available():
|
823
|
+
self._update_sam_model_image()
|
824
|
+
|
825
|
+
# Update channel threshold widget for new image
|
826
|
+
self._update_channel_threshold_for_image(pixmap)
|
827
|
+
|
828
|
+
# Restore crop coordinates for this image size if they exist
|
829
|
+
image_size = (pixmap.width(), pixmap.height())
|
830
|
+
if image_size in self.crop_coords_by_size:
|
831
|
+
self.current_crop_coords = self.crop_coords_by_size[image_size]
|
832
|
+
x1, y1, x2, y2 = self.current_crop_coords
|
833
|
+
self.control_panel.set_crop_coordinates(x1, y1, x2, y2)
|
834
|
+
self._apply_crop_to_image()
|
835
|
+
else:
|
836
|
+
self.current_crop_coords = None
|
837
|
+
self.control_panel.clear_crop_coordinates()
|
838
|
+
|
839
|
+
# Cache original image for channel threshold processing
|
840
|
+
self._cache_original_image()
|
841
|
+
|
842
|
+
self._show_success_notification(f"Loaded: {Path(self.current_image_path).name}")
|
843
|
+
|
617
844
|
def _update_sam_model_image(self):
|
618
845
|
"""Updates the SAM model's image based on the 'Operate On View' setting."""
|
619
846
|
if not self.model_manager.is_model_available() or not self.current_image_path:
|
@@ -634,12 +861,9 @@ class MainWindow(QMainWindow):
|
|
634
861
|
self.model_manager.sam_model.set_image_from_path(self.current_image_path)
|
635
862
|
|
636
863
|
def _load_next_image(self):
|
637
|
-
"""Load next image in the file list
|
864
|
+
"""Load next image in the file list."""
|
638
865
|
if not self.current_file_index.isValid():
|
639
866
|
return
|
640
|
-
# Auto-save if enabled
|
641
|
-
if self.control_panel.get_settings().get("auto_save", True):
|
642
|
-
self._save_output_to_npz()
|
643
867
|
parent = self.current_file_index.parent()
|
644
868
|
row = self.current_file_index.row()
|
645
869
|
# Find next valid image file
|
@@ -651,12 +875,9 @@ class MainWindow(QMainWindow):
|
|
651
875
|
return
|
652
876
|
|
653
877
|
def _load_previous_image(self):
|
654
|
-
"""Load previous image in the file list
|
878
|
+
"""Load previous image in the file list."""
|
655
879
|
if not self.current_file_index.isValid():
|
656
880
|
return
|
657
|
-
# Auto-save if enabled
|
658
|
-
if self.control_panel.get_settings().get("auto_save", True):
|
659
|
-
self._save_output_to_npz()
|
660
881
|
parent = self.current_file_index.parent()
|
661
882
|
row = self.current_file_index.row()
|
662
883
|
# Find previous valid image file
|
@@ -975,6 +1196,24 @@ class MainWindow(QMainWindow):
|
|
975
1196
|
self.positive_points, self.negative_points
|
976
1197
|
)
|
977
1198
|
if mask is not None:
|
1199
|
+
# COORDINATE TRANSFORMATION FIX: Scale mask back up to display size if needed
|
1200
|
+
if (
|
1201
|
+
self.sam_scale_factor != 1.0
|
1202
|
+
and self.viewer._pixmap_item
|
1203
|
+
and not self.viewer._pixmap_item.pixmap().isNull()
|
1204
|
+
):
|
1205
|
+
# Get original image dimensions
|
1206
|
+
original_height = self.viewer._pixmap_item.pixmap().height()
|
1207
|
+
original_width = self.viewer._pixmap_item.pixmap().width()
|
1208
|
+
|
1209
|
+
# Resize mask back to original dimensions for saving
|
1210
|
+
mask_resized = cv2.resize(
|
1211
|
+
mask.astype(np.uint8),
|
1212
|
+
(original_width, original_height),
|
1213
|
+
interpolation=cv2.INTER_NEAREST,
|
1214
|
+
).astype(bool)
|
1215
|
+
mask = mask_resized
|
1216
|
+
|
978
1217
|
# Apply fragment threshold filtering if enabled
|
979
1218
|
filtered_mask = self._apply_fragment_threshold(mask)
|
980
1219
|
if filtered_mask is not None:
|
@@ -1110,7 +1349,10 @@ class MainWindow(QMainWindow):
|
|
1110
1349
|
class_order = self.segment_manager.get_unique_class_ids()
|
1111
1350
|
if class_order:
|
1112
1351
|
npz_path = self.file_manager.save_npz(
|
1113
|
-
self.current_image_path,
|
1352
|
+
self.current_image_path,
|
1353
|
+
(h, w),
|
1354
|
+
class_order,
|
1355
|
+
self.current_crop_coords,
|
1114
1356
|
)
|
1115
1357
|
self._show_success_notification(
|
1116
1358
|
f"Saved: {os.path.basename(npz_path)}"
|
@@ -1131,7 +1373,11 @@ class MainWindow(QMainWindow):
|
|
1131
1373
|
class_labels = [str(cid) for cid in class_order]
|
1132
1374
|
if class_order:
|
1133
1375
|
txt_path = self.file_manager.save_yolo_txt(
|
1134
|
-
self.current_image_path,
|
1376
|
+
self.current_image_path,
|
1377
|
+
(h, w),
|
1378
|
+
class_order,
|
1379
|
+
class_labels,
|
1380
|
+
self.current_crop_coords,
|
1135
1381
|
)
|
1136
1382
|
# Efficiently update file list tickboxes and highlight
|
1137
1383
|
for path in [npz_path, txt_path]:
|
@@ -1440,6 +1686,17 @@ class MainWindow(QMainWindow):
|
|
1440
1686
|
self.clear_all_points()
|
1441
1687
|
self.segment_manager.clear()
|
1442
1688
|
self._update_all_lists()
|
1689
|
+
|
1690
|
+
# Clean up crop visuals
|
1691
|
+
self._remove_crop_visual()
|
1692
|
+
self._remove_crop_hover_overlay()
|
1693
|
+
self._remove_crop_hover_effect()
|
1694
|
+
|
1695
|
+
# Reset crop state
|
1696
|
+
self.crop_mode = False
|
1697
|
+
self.crop_start_pos = None
|
1698
|
+
self.current_crop_coords = None
|
1699
|
+
|
1443
1700
|
items_to_remove = [
|
1444
1701
|
item
|
1445
1702
|
for item in self.viewer.scene().items()
|
@@ -1518,6 +1775,13 @@ class MainWindow(QMainWindow):
|
|
1518
1775
|
self.viewer.scene().addItem(self.rubber_band_rect)
|
1519
1776
|
elif self.mode == "selection" and event.button() == Qt.MouseButton.LeftButton:
|
1520
1777
|
self._handle_segment_selection_click(pos)
|
1778
|
+
elif self.mode == "crop" and event.button() == Qt.MouseButton.LeftButton:
|
1779
|
+
self.crop_start_pos = pos
|
1780
|
+
self.crop_rect_item = QGraphicsRectItem()
|
1781
|
+
self.crop_rect_item.setPen(
|
1782
|
+
QPen(Qt.GlobalColor.blue, 2, Qt.PenStyle.DashLine)
|
1783
|
+
)
|
1784
|
+
self.viewer.scene().addItem(self.crop_rect_item)
|
1521
1785
|
|
1522
1786
|
def _scene_mouse_move(self, event):
|
1523
1787
|
"""Handle mouse move events in the scene."""
|
@@ -1547,6 +1811,13 @@ class MainWindow(QMainWindow):
|
|
1547
1811
|
event.accept()
|
1548
1812
|
return
|
1549
1813
|
|
1814
|
+
if self.mode == "crop" and self.crop_rect_item and self.crop_start_pos:
|
1815
|
+
current_pos = event.scenePos()
|
1816
|
+
rect = QRectF(self.crop_start_pos, current_pos).normalized()
|
1817
|
+
self.crop_rect_item.setRect(rect)
|
1818
|
+
event.accept()
|
1819
|
+
return
|
1820
|
+
|
1550
1821
|
def _scene_mouse_release(self, event):
|
1551
1822
|
"""Handle mouse release events in the scene."""
|
1552
1823
|
if self.mode == "edit" and self.is_dragging_polygon:
|
@@ -1611,12 +1882,53 @@ class MainWindow(QMainWindow):
|
|
1611
1882
|
event.accept()
|
1612
1883
|
return
|
1613
1884
|
|
1885
|
+
if self.mode == "crop" and self.crop_rect_item:
|
1886
|
+
rect = self.crop_rect_item.rect()
|
1887
|
+
# Clean up the drawing rectangle
|
1888
|
+
self.viewer.scene().removeItem(self.crop_rect_item)
|
1889
|
+
self.crop_rect_item = None
|
1890
|
+
self.crop_start_pos = None
|
1891
|
+
|
1892
|
+
if rect.width() > 5 and rect.height() > 5: # Minimum crop size
|
1893
|
+
# Get actual crop coordinates
|
1894
|
+
x1, y1 = int(rect.left()), int(rect.top())
|
1895
|
+
x2, y2 = int(rect.right()), int(rect.bottom())
|
1896
|
+
|
1897
|
+
# Apply the crop coordinates
|
1898
|
+
self._apply_crop_coordinates(x1, y1, x2, y2)
|
1899
|
+
self.crop_mode = False
|
1900
|
+
self._set_mode("sam_points") # Return to default mode
|
1901
|
+
|
1902
|
+
event.accept()
|
1903
|
+
return
|
1904
|
+
|
1614
1905
|
self._original_mouse_release(event)
|
1615
1906
|
|
1616
1907
|
def _add_point(self, pos, positive):
|
1617
1908
|
"""Add a point for SAM segmentation."""
|
1909
|
+
# RACE CONDITION FIX: Block clicks during SAM updates
|
1910
|
+
if self.sam_is_updating:
|
1911
|
+
self._show_warning_notification(
|
1912
|
+
"AI model is updating, please wait...", 2000
|
1913
|
+
)
|
1914
|
+
return
|
1915
|
+
|
1916
|
+
# Ensure SAM is updated before using it
|
1917
|
+
self._ensure_sam_updated()
|
1918
|
+
|
1919
|
+
# Wait for SAM to finish updating if it started
|
1920
|
+
if self.sam_is_updating:
|
1921
|
+
self._show_warning_notification(
|
1922
|
+
"AI model is updating, please wait...", 2000
|
1923
|
+
)
|
1924
|
+
return
|
1925
|
+
|
1926
|
+
# COORDINATE TRANSFORMATION FIX: Scale coordinates for SAM model
|
1927
|
+
sam_x = int(pos.x() * self.sam_scale_factor)
|
1928
|
+
sam_y = int(pos.y() * self.sam_scale_factor)
|
1929
|
+
|
1618
1930
|
point_list = self.positive_points if positive else self.negative_points
|
1619
|
-
point_list.append([
|
1931
|
+
point_list.append([sam_x, sam_y])
|
1620
1932
|
|
1621
1933
|
point_color = (
|
1622
1934
|
QColor(Qt.GlobalColor.green) if positive else QColor(Qt.GlobalColor.red)
|
@@ -1635,12 +1947,13 @@ class MainWindow(QMainWindow):
|
|
1635
1947
|
self.viewer.scene().addItem(point_item)
|
1636
1948
|
self.point_items.append(point_item)
|
1637
1949
|
|
1638
|
-
# Record the action for undo
|
1950
|
+
# Record the action for undo (store display coordinates)
|
1639
1951
|
self.action_history.append(
|
1640
1952
|
{
|
1641
1953
|
"type": "add_point",
|
1642
1954
|
"point_type": "positive" if positive else "negative",
|
1643
|
-
"point_coords": [int(pos.x()), int(pos.y())],
|
1955
|
+
"point_coords": [int(pos.x()), int(pos.y())], # Display coordinates
|
1956
|
+
"sam_coords": [sam_x, sam_y], # SAM coordinates
|
1644
1957
|
"point_item": point_item,
|
1645
1958
|
}
|
1646
1959
|
)
|
@@ -1658,6 +1971,24 @@ class MainWindow(QMainWindow):
|
|
1658
1971
|
self.positive_points, self.negative_points
|
1659
1972
|
)
|
1660
1973
|
if mask is not None:
|
1974
|
+
# COORDINATE TRANSFORMATION FIX: Scale mask back up to display size if needed
|
1975
|
+
if (
|
1976
|
+
self.sam_scale_factor != 1.0
|
1977
|
+
and self.viewer._pixmap_item
|
1978
|
+
and not self.viewer._pixmap_item.pixmap().isNull()
|
1979
|
+
):
|
1980
|
+
# Get original image dimensions
|
1981
|
+
original_height = self.viewer._pixmap_item.pixmap().height()
|
1982
|
+
original_width = self.viewer._pixmap_item.pixmap().width()
|
1983
|
+
|
1984
|
+
# Resize mask back to original dimensions for saving
|
1985
|
+
mask_resized = cv2.resize(
|
1986
|
+
mask.astype(np.uint8),
|
1987
|
+
(original_width, original_height),
|
1988
|
+
interpolation=cv2.INTER_NEAREST,
|
1989
|
+
).astype(bool)
|
1990
|
+
mask = mask_resized
|
1991
|
+
|
1661
1992
|
pixmap = mask_to_pixmap(mask, (255, 255, 0))
|
1662
1993
|
self.preview_mask_item = self.viewer.scene().addPixmap(pixmap)
|
1663
1994
|
self.preview_mask_item.setZValue(50)
|
@@ -2018,3 +2349,659 @@ class MainWindow(QMainWindow):
|
|
2018
2349
|
|
2019
2350
|
# Restore splitter sizes
|
2020
2351
|
self.main_splitter.setSizes([250, 800, 350])
|
2352
|
+
|
2353
|
+
# Additional methods for new features
|
2354
|
+
def _handle_settings_changed(self):
|
2355
|
+
"""Handle changes in settings, e.g., 'Operate On View'."""
|
2356
|
+
# Update the main window's settings object with the latest from the widget
|
2357
|
+
self.settings.update(**self.control_panel.settings_widget.get_settings())
|
2358
|
+
|
2359
|
+
# Re-load the current image to apply the new 'Operate On View' setting
|
2360
|
+
if self.current_image_path:
|
2361
|
+
self._update_sam_model_image()
|
2362
|
+
|
2363
|
+
def _handle_channel_threshold_changed(self):
|
2364
|
+
"""Handle changes in channel thresholding - optimized to avoid unnecessary work."""
|
2365
|
+
if not self.current_image_path:
|
2366
|
+
return
|
2367
|
+
|
2368
|
+
# Get channel threshold widget
|
2369
|
+
threshold_widget = self.control_panel.get_channel_threshold_widget()
|
2370
|
+
|
2371
|
+
# Check if there's actually any active thresholding
|
2372
|
+
if not threshold_widget.has_active_thresholding():
|
2373
|
+
# No active thresholding - just reload original image without SAM update
|
2374
|
+
self._reload_original_image_without_sam()
|
2375
|
+
return
|
2376
|
+
|
2377
|
+
# Always update visuals immediately for responsive UI
|
2378
|
+
self._apply_channel_thresholding_fast()
|
2379
|
+
|
2380
|
+
# Mark SAM as dirty instead of updating immediately
|
2381
|
+
# Only update SAM when user actually needs it (enters SAM mode)
|
2382
|
+
if self.settings.operate_on_view:
|
2383
|
+
self._mark_sam_dirty()
|
2384
|
+
|
2385
|
+
def _mark_sam_dirty(self):
|
2386
|
+
"""Mark SAM model as needing update, but don't update immediately."""
|
2387
|
+
self.sam_is_dirty = True
|
2388
|
+
# Cancel any pending SAM updates since we're going lazy
|
2389
|
+
self.sam_update_timer.stop()
|
2390
|
+
|
2391
|
+
def _ensure_sam_updated(self):
|
2392
|
+
"""Ensure SAM model is up-to-date when user needs it (lazy update with threading)."""
|
2393
|
+
if not self.sam_is_dirty or self.sam_is_updating:
|
2394
|
+
return
|
2395
|
+
|
2396
|
+
if not self.current_image_path or not self.model_manager.is_model_available():
|
2397
|
+
return
|
2398
|
+
|
2399
|
+
# Get current image (with modifications if operate_on_view is enabled)
|
2400
|
+
current_image = None
|
2401
|
+
image_hash = None
|
2402
|
+
|
2403
|
+
if (
|
2404
|
+
self.settings.operate_on_view
|
2405
|
+
and hasattr(self, "_cached_original_image")
|
2406
|
+
and self._cached_original_image is not None
|
2407
|
+
):
|
2408
|
+
# Apply current modifications to get the view image
|
2409
|
+
current_image = self._get_current_modified_image()
|
2410
|
+
image_hash = self._get_image_hash(current_image)
|
2411
|
+
else:
|
2412
|
+
# Use original image path as hash for non-modified images
|
2413
|
+
image_hash = hashlib.md5(self.current_image_path.encode()).hexdigest()
|
2414
|
+
|
2415
|
+
# Check if this exact image state is already loaded in SAM
|
2416
|
+
if image_hash and image_hash == self.current_sam_hash:
|
2417
|
+
# SAM already has this exact image state - no update needed
|
2418
|
+
self.sam_is_dirty = False
|
2419
|
+
return
|
2420
|
+
|
2421
|
+
# Stop any existing worker
|
2422
|
+
if self.sam_worker_thread and self.sam_worker_thread.isRunning():
|
2423
|
+
self.sam_worker_thread.stop()
|
2424
|
+
self.sam_worker_thread.wait(1000) # Wait up to 1 second
|
2425
|
+
|
2426
|
+
# Show status message
|
2427
|
+
if hasattr(self, "status_bar"):
|
2428
|
+
self.status_bar.show_message("Loading image view into AI model...", 0)
|
2429
|
+
|
2430
|
+
# Mark as updating
|
2431
|
+
self.sam_is_updating = True
|
2432
|
+
self.sam_is_dirty = False
|
2433
|
+
|
2434
|
+
# Create and start worker thread
|
2435
|
+
self.sam_worker_thread = SAMUpdateWorker(
|
2436
|
+
self.model_manager,
|
2437
|
+
self.current_image_path,
|
2438
|
+
self.settings.operate_on_view,
|
2439
|
+
current_image, # Pass current image directly
|
2440
|
+
self,
|
2441
|
+
)
|
2442
|
+
self.sam_worker_thread.finished.connect(
|
2443
|
+
lambda: self._on_sam_update_finished(image_hash)
|
2444
|
+
)
|
2445
|
+
self.sam_worker_thread.error.connect(self._on_sam_update_error)
|
2446
|
+
|
2447
|
+
self.sam_worker_thread.start()
|
2448
|
+
|
2449
|
+
def _on_sam_update_finished(self, image_hash):
|
2450
|
+
"""Handle completion of SAM update in background thread."""
|
2451
|
+
self.sam_is_updating = False
|
2452
|
+
|
2453
|
+
# Clear status message
|
2454
|
+
if hasattr(self, "status_bar"):
|
2455
|
+
self.status_bar.clear_message()
|
2456
|
+
|
2457
|
+
# Update scale factor from worker thread
|
2458
|
+
if self.sam_worker_thread:
|
2459
|
+
self.sam_scale_factor = self.sam_worker_thread.get_scale_factor()
|
2460
|
+
|
2461
|
+
# Clean up worker thread
|
2462
|
+
if self.sam_worker_thread:
|
2463
|
+
self.sam_worker_thread.deleteLater()
|
2464
|
+
self.sam_worker_thread = None
|
2465
|
+
|
2466
|
+
# Update current_sam_hash after successful update
|
2467
|
+
self.current_sam_hash = image_hash
|
2468
|
+
|
2469
|
+
def _on_sam_update_error(self, error_message):
|
2470
|
+
"""Handle error during SAM update."""
|
2471
|
+
self.sam_is_updating = False
|
2472
|
+
|
2473
|
+
# Show error in status bar
|
2474
|
+
if hasattr(self, "status_bar"):
|
2475
|
+
self.status_bar.show_message(
|
2476
|
+
f"Error loading AI model: {error_message}", 5000
|
2477
|
+
)
|
2478
|
+
|
2479
|
+
# Clean up worker thread
|
2480
|
+
if self.sam_worker_thread:
|
2481
|
+
self.sam_worker_thread.deleteLater()
|
2482
|
+
self.sam_worker_thread = None
|
2483
|
+
|
2484
|
+
def _get_current_modified_image(self):
|
2485
|
+
"""Get the current image with all modifications applied (excluding crop for SAM)."""
|
2486
|
+
if self._cached_original_image is None:
|
2487
|
+
return None
|
2488
|
+
|
2489
|
+
# Start with cached original
|
2490
|
+
result_image = self._cached_original_image.copy()
|
2491
|
+
|
2492
|
+
# Apply channel thresholding if active
|
2493
|
+
threshold_widget = self.control_panel.get_channel_threshold_widget()
|
2494
|
+
if threshold_widget and threshold_widget.has_active_thresholding():
|
2495
|
+
result_image = threshold_widget.apply_thresholding(result_image)
|
2496
|
+
|
2497
|
+
# NOTE: Crop is NOT applied here - it's only a visual overlay and should only affect saved masks
|
2498
|
+
# The crop visual overlay is handled by _apply_crop_to_image() which adds QGraphicsRectItem overlays
|
2499
|
+
|
2500
|
+
return result_image
|
2501
|
+
|
2502
|
+
def _get_image_hash(self, image_array=None):
|
2503
|
+
"""Compute hash of current image state for caching (excluding crop)."""
|
2504
|
+
if image_array is None:
|
2505
|
+
image_array = self._get_current_modified_image()
|
2506
|
+
|
2507
|
+
if image_array is None:
|
2508
|
+
return None
|
2509
|
+
|
2510
|
+
# Create hash based on image content and modifications
|
2511
|
+
hasher = hashlib.md5()
|
2512
|
+
hasher.update(image_array.tobytes())
|
2513
|
+
|
2514
|
+
# Include modification parameters in hash
|
2515
|
+
threshold_widget = self.control_panel.get_channel_threshold_widget()
|
2516
|
+
if threshold_widget and threshold_widget.has_active_thresholding():
|
2517
|
+
# Add threshold parameters to hash
|
2518
|
+
params = str(threshold_widget.get_threshold_params()).encode()
|
2519
|
+
hasher.update(params)
|
2520
|
+
|
2521
|
+
# NOTE: Crop coordinates are NOT included in hash since crop doesn't affect SAM processing
|
2522
|
+
# Crop is only a visual overlay and affects final saved masks, not the AI model input
|
2523
|
+
|
2524
|
+
return hasher.hexdigest()
|
2525
|
+
|
2526
|
+
def _reload_original_image_without_sam(self):
|
2527
|
+
"""Reload original image without triggering expensive SAM update."""
|
2528
|
+
if not self.current_image_path:
|
2529
|
+
return
|
2530
|
+
|
2531
|
+
pixmap = QPixmap(self.current_image_path)
|
2532
|
+
if not pixmap.isNull():
|
2533
|
+
self.viewer.set_photo(pixmap)
|
2534
|
+
self.viewer.set_image_adjustments(
|
2535
|
+
self.brightness, self.contrast, self.gamma
|
2536
|
+
)
|
2537
|
+
# Reapply crop overlays if they exist
|
2538
|
+
if self.current_crop_coords:
|
2539
|
+
self._apply_crop_to_image()
|
2540
|
+
# Clear cached image
|
2541
|
+
self._cached_original_image = None
|
2542
|
+
# Don't call _update_sam_model_image() - that's the expensive part!
|
2543
|
+
|
2544
|
+
def _apply_channel_thresholding_fast(self):
|
2545
|
+
"""Apply channel thresholding using cached image data for better performance."""
|
2546
|
+
if not self.current_image_path:
|
2547
|
+
return
|
2548
|
+
|
2549
|
+
# Get channel threshold widget
|
2550
|
+
threshold_widget = self.control_panel.get_channel_threshold_widget()
|
2551
|
+
|
2552
|
+
# If no active thresholding, reload original image
|
2553
|
+
if not threshold_widget.has_active_thresholding():
|
2554
|
+
self._reload_original_image_without_sam()
|
2555
|
+
return
|
2556
|
+
|
2557
|
+
# Use cached image array if available, otherwise load and cache
|
2558
|
+
if (
|
2559
|
+
not hasattr(self, "_cached_original_image")
|
2560
|
+
or self._cached_original_image is None
|
2561
|
+
):
|
2562
|
+
self._cache_original_image()
|
2563
|
+
|
2564
|
+
if self._cached_original_image is None:
|
2565
|
+
return
|
2566
|
+
|
2567
|
+
# Apply thresholding to cached image
|
2568
|
+
thresholded_image = threshold_widget.apply_thresholding(
|
2569
|
+
self._cached_original_image
|
2570
|
+
)
|
2571
|
+
|
2572
|
+
# Convert back to QPixmap efficiently
|
2573
|
+
qimage = self._numpy_to_qimage(thresholded_image)
|
2574
|
+
thresholded_pixmap = QPixmap.fromImage(qimage)
|
2575
|
+
|
2576
|
+
# Apply to viewer
|
2577
|
+
self.viewer.set_photo(thresholded_pixmap)
|
2578
|
+
self.viewer.set_image_adjustments(self.brightness, self.contrast, self.gamma)
|
2579
|
+
|
2580
|
+
# Reapply crop overlays if they exist
|
2581
|
+
if self.current_crop_coords:
|
2582
|
+
self._apply_crop_to_image()
|
2583
|
+
|
2584
|
+
def _cache_original_image(self):
|
2585
|
+
"""Cache the original image as numpy array for fast processing."""
|
2586
|
+
if not self.current_image_path:
|
2587
|
+
self._cached_original_image = None
|
2588
|
+
return
|
2589
|
+
|
2590
|
+
# Load original image
|
2591
|
+
pixmap = QPixmap(self.current_image_path)
|
2592
|
+
if pixmap.isNull():
|
2593
|
+
self._cached_original_image = None
|
2594
|
+
return
|
2595
|
+
|
2596
|
+
# Convert pixmap to numpy array
|
2597
|
+
qimage = pixmap.toImage()
|
2598
|
+
ptr = qimage.constBits()
|
2599
|
+
ptr.setsize(qimage.bytesPerLine() * qimage.height())
|
2600
|
+
image_np = np.array(ptr).reshape(qimage.height(), qimage.width(), 4)
|
2601
|
+
# Convert from BGRA to RGB
|
2602
|
+
self._cached_original_image = image_np[
|
2603
|
+
:, :, [2, 1, 0]
|
2604
|
+
] # BGR to RGB, ignore alpha
|
2605
|
+
|
2606
|
+
def _numpy_to_qimage(self, image_array):
|
2607
|
+
"""Convert numpy array to QImage efficiently."""
|
2608
|
+
# Ensure array is contiguous
|
2609
|
+
image_array = np.ascontiguousarray(image_array)
|
2610
|
+
|
2611
|
+
if len(image_array.shape) == 2:
|
2612
|
+
# Grayscale
|
2613
|
+
height, width = image_array.shape
|
2614
|
+
bytes_per_line = width
|
2615
|
+
return QImage(
|
2616
|
+
bytes(image_array.data), # Convert memoryview to bytes
|
2617
|
+
width,
|
2618
|
+
height,
|
2619
|
+
bytes_per_line,
|
2620
|
+
QImage.Format.Format_Grayscale8,
|
2621
|
+
)
|
2622
|
+
else:
|
2623
|
+
# RGB
|
2624
|
+
height, width, channels = image_array.shape
|
2625
|
+
bytes_per_line = width * channels
|
2626
|
+
return QImage(
|
2627
|
+
bytes(image_array.data), # Convert memoryview to bytes
|
2628
|
+
width,
|
2629
|
+
height,
|
2630
|
+
bytes_per_line,
|
2631
|
+
QImage.Format.Format_RGB888,
|
2632
|
+
)
|
2633
|
+
|
2634
|
+
def _apply_channel_thresholding(self):
|
2635
|
+
"""Apply channel thresholding to the current image - legacy method."""
|
2636
|
+
# Use the optimized version
|
2637
|
+
self._apply_channel_thresholding_fast()
|
2638
|
+
|
2639
|
+
def _update_channel_threshold_for_image(self, pixmap):
|
2640
|
+
"""Update channel threshold widget for the given image pixmap."""
|
2641
|
+
if pixmap.isNull():
|
2642
|
+
self.control_panel.update_channel_threshold_for_image(None)
|
2643
|
+
return
|
2644
|
+
|
2645
|
+
# Convert pixmap to numpy array
|
2646
|
+
qimage = pixmap.toImage()
|
2647
|
+
ptr = qimage.constBits()
|
2648
|
+
ptr.setsize(qimage.bytesPerLine() * qimage.height())
|
2649
|
+
image_np = np.array(ptr).reshape(qimage.height(), qimage.width(), 4)
|
2650
|
+
# Convert from BGRA to RGB, ignore alpha
|
2651
|
+
image_rgb = image_np[:, :, [2, 1, 0]]
|
2652
|
+
|
2653
|
+
# Check if image is grayscale (all channels are the same)
|
2654
|
+
if np.array_equal(image_rgb[:, :, 0], image_rgb[:, :, 1]) and np.array_equal(
|
2655
|
+
image_rgb[:, :, 1], image_rgb[:, :, 2]
|
2656
|
+
):
|
2657
|
+
# Convert to single channel grayscale
|
2658
|
+
image_array = image_rgb[:, :, 0]
|
2659
|
+
else:
|
2660
|
+
# Keep as RGB
|
2661
|
+
image_array = image_rgb
|
2662
|
+
|
2663
|
+
# Update the channel threshold widget
|
2664
|
+
self.control_panel.update_channel_threshold_for_image(image_array)
|
2665
|
+
|
2666
|
+
# Border crop methods
|
2667
|
+
def _start_crop_drawing(self):
|
2668
|
+
"""Start crop drawing mode."""
|
2669
|
+
self.crop_mode = True
|
2670
|
+
self._set_mode("crop")
|
2671
|
+
self.control_panel.set_crop_status("Click and drag to draw crop rectangle")
|
2672
|
+
self._show_notification("Click and drag to draw crop rectangle")
|
2673
|
+
|
2674
|
+
def _clear_crop(self):
|
2675
|
+
"""Clear current crop."""
|
2676
|
+
self.current_crop_coords = None
|
2677
|
+
self.control_panel.clear_crop_coordinates()
|
2678
|
+
self._remove_crop_visual()
|
2679
|
+
if self.current_image_path:
|
2680
|
+
# Clear crop for current image size
|
2681
|
+
pixmap = QPixmap(self.current_image_path)
|
2682
|
+
if not pixmap.isNull():
|
2683
|
+
image_size = (pixmap.width(), pixmap.height())
|
2684
|
+
if image_size in self.crop_coords_by_size:
|
2685
|
+
del self.crop_coords_by_size[image_size]
|
2686
|
+
self._show_notification("Crop cleared")
|
2687
|
+
|
2688
|
+
def _apply_crop_coordinates(self, x1, y1, x2, y2):
|
2689
|
+
"""Apply crop coordinates from text input."""
|
2690
|
+
if not self.current_image_path:
|
2691
|
+
self.control_panel.set_crop_status("No image loaded")
|
2692
|
+
return
|
2693
|
+
|
2694
|
+
pixmap = QPixmap(self.current_image_path)
|
2695
|
+
if pixmap.isNull():
|
2696
|
+
self.control_panel.set_crop_status("Invalid image")
|
2697
|
+
return
|
2698
|
+
|
2699
|
+
# Round to nearest pixel
|
2700
|
+
x1, y1, x2, y2 = round(x1), round(y1), round(x2), round(y2)
|
2701
|
+
|
2702
|
+
# Validate coordinates are within image bounds
|
2703
|
+
img_width, img_height = pixmap.width(), pixmap.height()
|
2704
|
+
x1 = max(0, min(x1, img_width - 1))
|
2705
|
+
x2 = max(0, min(x2, img_width - 1))
|
2706
|
+
y1 = max(0, min(y1, img_height - 1))
|
2707
|
+
y2 = max(0, min(y2, img_height - 1))
|
2708
|
+
|
2709
|
+
# Ensure proper ordering
|
2710
|
+
if x1 > x2:
|
2711
|
+
x1, x2 = x2, x1
|
2712
|
+
if y1 > y2:
|
2713
|
+
y1, y2 = y2, y1
|
2714
|
+
|
2715
|
+
# Store crop coordinates
|
2716
|
+
self.current_crop_coords = (x1, y1, x2, y2)
|
2717
|
+
image_size = (img_width, img_height)
|
2718
|
+
self.crop_coords_by_size[image_size] = self.current_crop_coords
|
2719
|
+
|
2720
|
+
# Update display coordinates in case they were adjusted
|
2721
|
+
self.control_panel.set_crop_coordinates(x1, y1, x2, y2)
|
2722
|
+
|
2723
|
+
# Apply crop to current image
|
2724
|
+
self._apply_crop_to_image()
|
2725
|
+
self._show_notification(f"Crop applied: {x1}:{x2}, {y1}:{y2}")
|
2726
|
+
|
2727
|
+
def _apply_crop_to_image(self):
|
2728
|
+
"""Add visual overlays to show crop areas."""
|
2729
|
+
if not self.current_crop_coords or not self.current_image_path:
|
2730
|
+
return
|
2731
|
+
|
2732
|
+
# Add visual crop overlays
|
2733
|
+
self._add_crop_visual_overlays()
|
2734
|
+
|
2735
|
+
# Add crop hover overlay
|
2736
|
+
self._add_crop_hover_overlay()
|
2737
|
+
|
2738
|
+
def _add_crop_visual_overlays(self):
|
2739
|
+
"""Add simple black overlays to show cropped areas."""
|
2740
|
+
if not self.current_crop_coords:
|
2741
|
+
return
|
2742
|
+
|
2743
|
+
# Remove existing visual overlays
|
2744
|
+
self._remove_crop_visual_overlays()
|
2745
|
+
|
2746
|
+
x1, y1, x2, y2 = self.current_crop_coords
|
2747
|
+
|
2748
|
+
# Get image dimensions
|
2749
|
+
pixmap = QPixmap(self.current_image_path)
|
2750
|
+
if pixmap.isNull():
|
2751
|
+
return
|
2752
|
+
|
2753
|
+
img_width, img_height = pixmap.width(), pixmap.height()
|
2754
|
+
|
2755
|
+
# Import needed classes
|
2756
|
+
from PyQt6.QtCore import QRectF
|
2757
|
+
from PyQt6.QtGui import QBrush, QColor
|
2758
|
+
from PyQt6.QtWidgets import QGraphicsRectItem
|
2759
|
+
|
2760
|
+
# Create black overlays for the 4 cropped regions
|
2761
|
+
self.crop_visual_overlays = []
|
2762
|
+
|
2763
|
+
# Semi-transparent black color
|
2764
|
+
overlay_color = QColor(0, 0, 0, 120) # Black with transparency
|
2765
|
+
|
2766
|
+
# Top rectangle
|
2767
|
+
if y1 > 0:
|
2768
|
+
top_overlay = QGraphicsRectItem(QRectF(0, 0, img_width, y1))
|
2769
|
+
top_overlay.setBrush(QBrush(overlay_color))
|
2770
|
+
top_overlay.setPen(QPen(Qt.GlobalColor.transparent))
|
2771
|
+
top_overlay.setZValue(25) # Above image but below other UI elements
|
2772
|
+
self.crop_visual_overlays.append(top_overlay)
|
2773
|
+
|
2774
|
+
# Bottom rectangle
|
2775
|
+
if y2 < img_height:
|
2776
|
+
bottom_overlay = QGraphicsRectItem(
|
2777
|
+
QRectF(0, y2, img_width, img_height - y2)
|
2778
|
+
)
|
2779
|
+
bottom_overlay.setBrush(QBrush(overlay_color))
|
2780
|
+
bottom_overlay.setPen(QPen(Qt.GlobalColor.transparent))
|
2781
|
+
bottom_overlay.setZValue(25)
|
2782
|
+
self.crop_visual_overlays.append(bottom_overlay)
|
2783
|
+
|
2784
|
+
# Left rectangle
|
2785
|
+
if x1 > 0:
|
2786
|
+
left_overlay = QGraphicsRectItem(QRectF(0, y1, x1, y2 - y1))
|
2787
|
+
left_overlay.setBrush(QBrush(overlay_color))
|
2788
|
+
left_overlay.setPen(QPen(Qt.GlobalColor.transparent))
|
2789
|
+
left_overlay.setZValue(25)
|
2790
|
+
self.crop_visual_overlays.append(left_overlay)
|
2791
|
+
|
2792
|
+
# Right rectangle
|
2793
|
+
if x2 < img_width:
|
2794
|
+
right_overlay = QGraphicsRectItem(QRectF(x2, y1, img_width - x2, y2 - y1))
|
2795
|
+
right_overlay.setBrush(QBrush(overlay_color))
|
2796
|
+
right_overlay.setPen(QPen(Qt.GlobalColor.transparent))
|
2797
|
+
right_overlay.setZValue(25)
|
2798
|
+
self.crop_visual_overlays.append(right_overlay)
|
2799
|
+
|
2800
|
+
# Add all visual overlays to scene
|
2801
|
+
for overlay in self.crop_visual_overlays:
|
2802
|
+
self.viewer.scene().addItem(overlay)
|
2803
|
+
|
2804
|
+
def _remove_crop_visual_overlays(self):
|
2805
|
+
"""Remove crop visual overlays."""
|
2806
|
+
if hasattr(self, "crop_visual_overlays"):
|
2807
|
+
for overlay in self.crop_visual_overlays:
|
2808
|
+
if overlay and overlay.scene():
|
2809
|
+
self.viewer.scene().removeItem(overlay)
|
2810
|
+
self.crop_visual_overlays = []
|
2811
|
+
|
2812
|
+
def _remove_crop_visual(self):
|
2813
|
+
"""Remove visual crop rectangle and overlays."""
|
2814
|
+
if self.crop_rect_item and self.crop_rect_item.scene():
|
2815
|
+
self.viewer.scene().removeItem(self.crop_rect_item)
|
2816
|
+
self.crop_rect_item = None
|
2817
|
+
|
2818
|
+
# Remove all crop-related visuals
|
2819
|
+
self._remove_crop_visual_overlays()
|
2820
|
+
self._remove_crop_hover_overlay()
|
2821
|
+
self._remove_crop_hover_effect()
|
2822
|
+
|
2823
|
+
def _add_crop_hover_overlay(self):
|
2824
|
+
"""Add invisible hover overlays for cropped areas (outside the crop rectangle)."""
|
2825
|
+
if not self.current_crop_coords:
|
2826
|
+
return
|
2827
|
+
|
2828
|
+
# Remove existing overlays
|
2829
|
+
self._remove_crop_hover_overlay()
|
2830
|
+
|
2831
|
+
x1, y1, x2, y2 = self.current_crop_coords
|
2832
|
+
|
2833
|
+
# Get image dimensions
|
2834
|
+
if not self.current_image_path:
|
2835
|
+
return
|
2836
|
+
pixmap = QPixmap(self.current_image_path)
|
2837
|
+
if pixmap.isNull():
|
2838
|
+
return
|
2839
|
+
|
2840
|
+
img_width, img_height = pixmap.width(), pixmap.height()
|
2841
|
+
|
2842
|
+
# Import needed classes
|
2843
|
+
from PyQt6.QtCore import QRectF
|
2844
|
+
from PyQt6.QtGui import QBrush, QPen
|
2845
|
+
from PyQt6.QtWidgets import QGraphicsRectItem
|
2846
|
+
|
2847
|
+
# Create hover overlays for the 4 cropped regions (outside the crop rectangle)
|
2848
|
+
self.crop_hover_overlays = []
|
2849
|
+
|
2850
|
+
# Top rectangle (0, 0, img_width, y1)
|
2851
|
+
if y1 > 0:
|
2852
|
+
top_overlay = QGraphicsRectItem(QRectF(0, 0, img_width, y1))
|
2853
|
+
self.crop_hover_overlays.append(top_overlay)
|
2854
|
+
|
2855
|
+
# Bottom rectangle (0, y2, img_width, img_height - y2)
|
2856
|
+
if y2 < img_height:
|
2857
|
+
bottom_overlay = QGraphicsRectItem(
|
2858
|
+
QRectF(0, y2, img_width, img_height - y2)
|
2859
|
+
)
|
2860
|
+
self.crop_hover_overlays.append(bottom_overlay)
|
2861
|
+
|
2862
|
+
# Left rectangle (0, y1, x1, y2 - y1)
|
2863
|
+
if x1 > 0:
|
2864
|
+
left_overlay = QGraphicsRectItem(QRectF(0, y1, x1, y2 - y1))
|
2865
|
+
self.crop_hover_overlays.append(left_overlay)
|
2866
|
+
|
2867
|
+
# Right rectangle (x2, y1, img_width - x2, y2 - y1)
|
2868
|
+
if x2 < img_width:
|
2869
|
+
right_overlay = QGraphicsRectItem(QRectF(x2, y1, img_width - x2, y2 - y1))
|
2870
|
+
self.crop_hover_overlays.append(right_overlay)
|
2871
|
+
|
2872
|
+
# Configure each overlay
|
2873
|
+
for overlay in self.crop_hover_overlays:
|
2874
|
+
overlay.setBrush(QBrush(QColor(0, 0, 0, 0))) # Transparent
|
2875
|
+
overlay.setPen(QPen(Qt.GlobalColor.transparent))
|
2876
|
+
overlay.setAcceptHoverEvents(True)
|
2877
|
+
overlay.setZValue(50) # Above image but below other items
|
2878
|
+
|
2879
|
+
# Custom hover events
|
2880
|
+
original_hover_enter = overlay.hoverEnterEvent
|
2881
|
+
original_hover_leave = overlay.hoverLeaveEvent
|
2882
|
+
|
2883
|
+
def hover_enter_event(event, orig_func=original_hover_enter):
|
2884
|
+
self._on_crop_hover_enter()
|
2885
|
+
orig_func(event)
|
2886
|
+
|
2887
|
+
def hover_leave_event(event, orig_func=original_hover_leave):
|
2888
|
+
self._on_crop_hover_leave()
|
2889
|
+
orig_func(event)
|
2890
|
+
|
2891
|
+
overlay.hoverEnterEvent = hover_enter_event
|
2892
|
+
overlay.hoverLeaveEvent = hover_leave_event
|
2893
|
+
|
2894
|
+
self.viewer.scene().addItem(overlay)
|
2895
|
+
|
2896
|
+
def _remove_crop_hover_overlay(self):
|
2897
|
+
"""Remove crop hover overlays."""
|
2898
|
+
if hasattr(self, "crop_hover_overlays"):
|
2899
|
+
for overlay in self.crop_hover_overlays:
|
2900
|
+
if overlay and overlay.scene():
|
2901
|
+
self.viewer.scene().removeItem(overlay)
|
2902
|
+
self.crop_hover_overlays = []
|
2903
|
+
self.is_hovering_crop = False
|
2904
|
+
|
2905
|
+
def _on_crop_hover_enter(self):
|
2906
|
+
"""Handle mouse entering crop area."""
|
2907
|
+
if not self.current_crop_coords:
|
2908
|
+
return
|
2909
|
+
|
2910
|
+
self.is_hovering_crop = True
|
2911
|
+
self._apply_crop_hover_effect()
|
2912
|
+
|
2913
|
+
def _on_crop_hover_leave(self):
|
2914
|
+
"""Handle mouse leaving crop area."""
|
2915
|
+
self.is_hovering_crop = False
|
2916
|
+
self._remove_crop_hover_effect()
|
2917
|
+
|
2918
|
+
def _apply_crop_hover_effect(self):
|
2919
|
+
"""Apply simple highlight to cropped areas on hover."""
|
2920
|
+
if not self.current_crop_coords or not self.current_image_path:
|
2921
|
+
return
|
2922
|
+
|
2923
|
+
# Remove existing hover effect
|
2924
|
+
self._remove_crop_hover_effect()
|
2925
|
+
|
2926
|
+
x1, y1, x2, y2 = self.current_crop_coords
|
2927
|
+
|
2928
|
+
# Get image dimensions
|
2929
|
+
pixmap = QPixmap(self.current_image_path)
|
2930
|
+
if pixmap.isNull():
|
2931
|
+
return
|
2932
|
+
|
2933
|
+
img_width, img_height = pixmap.width(), pixmap.height()
|
2934
|
+
|
2935
|
+
# Import needed classes
|
2936
|
+
from PyQt6.QtCore import QRectF
|
2937
|
+
from PyQt6.QtGui import QBrush, QColor
|
2938
|
+
from PyQt6.QtWidgets import QGraphicsRectItem
|
2939
|
+
|
2940
|
+
# Create simple colored overlays for the 4 cropped regions
|
2941
|
+
self.crop_hover_effect_items = []
|
2942
|
+
|
2943
|
+
# Use a simple semi-transparent yellow overlay
|
2944
|
+
hover_color = QColor(255, 255, 0, 60) # Light yellow with transparency
|
2945
|
+
|
2946
|
+
# Top rectangle
|
2947
|
+
if y1 > 0:
|
2948
|
+
top_effect = QGraphicsRectItem(QRectF(0, 0, img_width, y1))
|
2949
|
+
top_effect.setBrush(QBrush(hover_color))
|
2950
|
+
top_effect.setPen(QPen(Qt.GlobalColor.transparent))
|
2951
|
+
top_effect.setZValue(75) # Above crop overlay
|
2952
|
+
self.crop_hover_effect_items.append(top_effect)
|
2953
|
+
|
2954
|
+
# Bottom rectangle
|
2955
|
+
if y2 < img_height:
|
2956
|
+
bottom_effect = QGraphicsRectItem(QRectF(0, y2, img_width, img_height - y2))
|
2957
|
+
bottom_effect.setBrush(QBrush(hover_color))
|
2958
|
+
bottom_effect.setPen(QPen(Qt.GlobalColor.transparent))
|
2959
|
+
bottom_effect.setZValue(75)
|
2960
|
+
self.crop_hover_effect_items.append(bottom_effect)
|
2961
|
+
|
2962
|
+
# Left rectangle
|
2963
|
+
if x1 > 0:
|
2964
|
+
left_effect = QGraphicsRectItem(QRectF(0, y1, x1, y2 - y1))
|
2965
|
+
left_effect.setBrush(QBrush(hover_color))
|
2966
|
+
left_effect.setPen(QPen(Qt.GlobalColor.transparent))
|
2967
|
+
left_effect.setZValue(75)
|
2968
|
+
self.crop_hover_effect_items.append(left_effect)
|
2969
|
+
|
2970
|
+
# Right rectangle
|
2971
|
+
if x2 < img_width:
|
2972
|
+
right_effect = QGraphicsRectItem(QRectF(x2, y1, img_width - x2, y2 - y1))
|
2973
|
+
right_effect.setBrush(QBrush(hover_color))
|
2974
|
+
right_effect.setPen(QPen(Qt.GlobalColor.transparent))
|
2975
|
+
right_effect.setZValue(75)
|
2976
|
+
self.crop_hover_effect_items.append(right_effect)
|
2977
|
+
|
2978
|
+
# Add all hover effect items to scene
|
2979
|
+
for effect_item in self.crop_hover_effect_items:
|
2980
|
+
self.viewer.scene().addItem(effect_item)
|
2981
|
+
|
2982
|
+
def _remove_crop_hover_effect(self):
|
2983
|
+
"""Remove crop hover effect."""
|
2984
|
+
if hasattr(self, "crop_hover_effect_items"):
|
2985
|
+
for effect_item in self.crop_hover_effect_items:
|
2986
|
+
if effect_item and effect_item.scene():
|
2987
|
+
self.viewer.scene().removeItem(effect_item)
|
2988
|
+
self.crop_hover_effect_items = []
|
2989
|
+
|
2990
|
+
def _reload_current_image(self):
|
2991
|
+
"""Reload current image without crop."""
|
2992
|
+
if not self.current_image_path:
|
2993
|
+
return
|
2994
|
+
|
2995
|
+
pixmap = QPixmap(self.current_image_path)
|
2996
|
+
if not pixmap.isNull():
|
2997
|
+
self.viewer.set_photo(pixmap)
|
2998
|
+
self.viewer.set_image_adjustments(
|
2999
|
+
self.brightness, self.contrast, self.gamma
|
3000
|
+
)
|
3001
|
+
if self.model_manager.is_model_available():
|
3002
|
+
self._update_sam_model_image()
|
3003
|
+
|
3004
|
+
def _update_sam_model_image_debounced(self):
|
3005
|
+
"""Update SAM model image after debounce delay."""
|
3006
|
+
# This is called after the user stops interacting with sliders
|
3007
|
+
self._update_sam_model_image()
|