lazylabel-gui 1.1.7__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.
@@ -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.toggle_edit_mode,
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 mode."""
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"]:
@@ -620,6 +819,28 @@ class MainWindow(QMainWindow):
620
819
  self._update_all_lists()
621
820
  self.viewer.setFocus()
622
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
+
623
844
  def _update_sam_model_image(self):
624
845
  """Updates the SAM model's image based on the 'Operate On View' setting."""
625
846
  if not self.model_manager.is_model_available() or not self.current_image_path:
@@ -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, (h, w), class_order
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, (h, w), class_order, class_labels
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([int(pos.x()), int(pos.y())])
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()