lazylabel-gui 1.3.3__py3-none-any.whl → 1.3.5__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.
@@ -2,11 +2,12 @@
2
2
 
3
3
  import hashlib
4
4
  import os
5
+ import re
5
6
  from pathlib import Path
6
7
 
7
8
  import cv2
8
9
  import numpy as np
9
- from PyQt6.QtCore import QModelIndex, QPointF, QRectF, Qt, QThread, QTimer, pyqtSignal
10
+ from PyQt6.QtCore import QModelIndex, QPointF, QRectF, Qt, QTimer, pyqtSignal
10
11
  from PyQt6.QtGui import (
11
12
  QBrush,
12
13
  QColor,
@@ -19,12 +20,14 @@ from PyQt6.QtGui import (
19
20
  QShortcut,
20
21
  )
21
22
  from PyQt6.QtWidgets import (
23
+ QApplication,
22
24
  QDialog,
23
25
  QFileDialog,
24
26
  QGraphicsEllipseItem,
25
27
  QGraphicsLineItem,
26
28
  QGraphicsPolygonItem,
27
29
  QGraphicsRectItem,
30
+ QGridLayout,
28
31
  QHBoxLayout,
29
32
  QLabel,
30
33
  QMainWindow,
@@ -51,530 +54,13 @@ from .numeric_table_widget_item import NumericTableWidgetItem
51
54
  from .photo_viewer import PhotoViewer
52
55
  from .right_panel import RightPanel
53
56
  from .widgets import StatusBar
54
-
55
-
56
- class SAMUpdateWorker(QThread):
57
- """Worker thread for updating SAM model in background."""
58
-
59
- finished = pyqtSignal()
60
- error = pyqtSignal(str)
61
-
62
- def __init__(
63
- self,
64
- model_manager,
65
- image_path,
66
- operate_on_view,
67
- current_image=None,
68
- parent=None,
69
- ):
70
- super().__init__(parent)
71
- self.model_manager = model_manager
72
- self.image_path = image_path
73
- self.operate_on_view = operate_on_view
74
- self.current_image = current_image # Numpy array of current modified image
75
- self._should_stop = False
76
- self.scale_factor = 1.0 # Track scaling factor for coordinate transformation
77
-
78
- def stop(self):
79
- """Request the worker to stop."""
80
- self._should_stop = True
81
-
82
- def get_scale_factor(self):
83
- """Get the scale factor used for image resizing."""
84
- return self.scale_factor
85
-
86
- def run(self):
87
- """Run SAM update in background thread."""
88
- try:
89
- if self._should_stop:
90
- return
91
-
92
- if self.operate_on_view and self.current_image is not None:
93
- # Use the provided modified image
94
- if self._should_stop:
95
- return
96
-
97
- # Optimize image size for faster SAM processing
98
- image = self.current_image
99
- original_height, original_width = image.shape[:2]
100
- max_size = 1024
101
-
102
- if original_height > max_size or original_width > max_size:
103
- # Calculate scaling factor
104
- self.scale_factor = min(
105
- max_size / original_width, max_size / original_height
106
- )
107
- new_width = int(original_width * self.scale_factor)
108
- new_height = int(original_height * self.scale_factor)
109
-
110
- # Resize using OpenCV for speed
111
- image = cv2.resize(
112
- image, (new_width, new_height), interpolation=cv2.INTER_AREA
113
- )
114
- else:
115
- self.scale_factor = 1.0
116
-
117
- if self._should_stop:
118
- return
119
-
120
- # Set image from numpy array (FIXED: use resized image, not original)
121
- self.model_manager.set_image_from_array(image)
122
- else:
123
- # Load original image
124
- pixmap = QPixmap(self.image_path)
125
- if pixmap.isNull():
126
- self.error.emit("Failed to load image")
127
- return
128
-
129
- if self._should_stop:
130
- return
131
-
132
- original_width = pixmap.width()
133
- original_height = pixmap.height()
134
-
135
- # Optimize image size for faster SAM processing
136
- max_size = 1024
137
- if original_width > max_size or original_height > max_size:
138
- # Calculate scaling factor
139
- self.scale_factor = min(
140
- max_size / original_width, max_size / original_height
141
- )
142
-
143
- # Scale down while maintaining aspect ratio
144
- scaled_pixmap = pixmap.scaled(
145
- max_size,
146
- max_size,
147
- Qt.AspectRatioMode.KeepAspectRatio,
148
- Qt.TransformationMode.SmoothTransformation,
149
- )
150
-
151
- # Convert to numpy array for SAM
152
- qimage = scaled_pixmap.toImage()
153
- width = qimage.width()
154
- height = qimage.height()
155
- ptr = qimage.bits()
156
- ptr.setsize(height * width * 4)
157
- arr = np.array(ptr).reshape(height, width, 4)
158
- # Convert RGBA to RGB
159
- image_array = arr[:, :, :3]
160
-
161
- if self._should_stop:
162
- return
163
-
164
- # FIXED: Use the resized image array, not original path
165
- self.model_manager.set_image_from_array(image_array)
166
- else:
167
- self.scale_factor = 1.0
168
- # For images that don't need resizing, use original path
169
- self.model_manager.set_image_from_path(self.image_path)
170
-
171
- if not self._should_stop:
172
- self.finished.emit()
173
-
174
- except Exception as e:
175
- if not self._should_stop:
176
- self.error.emit(str(e))
177
-
178
-
179
- class SingleViewSAMInitWorker(QThread):
180
- """Worker thread for initializing single-view SAM model in background."""
181
-
182
- model_initialized = pyqtSignal(object) # model_instance
183
- finished = pyqtSignal()
184
- error = pyqtSignal(str)
185
- progress = pyqtSignal(str) # status message
186
-
187
- def __init__(self, model_manager, default_model_type, custom_model_path=None):
188
- super().__init__()
189
- self.model_manager = model_manager
190
- self.default_model_type = default_model_type
191
- self.custom_model_path = custom_model_path
192
- self._should_stop = False
193
-
194
- def stop(self):
195
- """Stop the worker gracefully."""
196
- self._should_stop = True
197
-
198
- def run(self):
199
- """Initialize SAM model in background."""
200
- try:
201
- if self._should_stop:
202
- return
203
-
204
- if self.custom_model_path:
205
- # Load custom model
206
- model_name = os.path.basename(self.custom_model_path)
207
- self.progress.emit(f"Loading {model_name}...")
208
-
209
- success = self.model_manager.load_custom_model(self.custom_model_path)
210
- if not success:
211
- raise Exception(f"Failed to load custom model: {model_name}")
212
-
213
- sam_model = self.model_manager.sam_model
214
- else:
215
- # Initialize the default model
216
- self.progress.emit("Initializing AI model...")
217
- sam_model = self.model_manager.initialize_default_model(
218
- self.default_model_type
219
- )
220
-
221
- if self._should_stop:
222
- return
223
-
224
- if sam_model and sam_model.is_loaded:
225
- self.model_initialized.emit(sam_model)
226
- self.progress.emit("AI model initialized")
227
- else:
228
- self.error.emit("Model failed to load")
229
-
230
- except Exception as e:
231
- if not self._should_stop:
232
- self.error.emit(f"Failed to load AI model: {str(e)}")
233
-
234
-
235
- class MultiViewSAMInitWorker(QThread):
236
- """Worker thread for initializing multi-view SAM models in background."""
237
-
238
- model_initialized = pyqtSignal(int, object) # viewer_index, model_instance
239
- all_models_initialized = pyqtSignal(int) # total_models_count
240
- error = pyqtSignal(str)
241
- progress = pyqtSignal(int, int) # current, total
242
-
243
- def __init__(self, model_manager, parent=None):
244
- super().__init__(parent)
245
- self.model_manager = model_manager
246
- self._should_stop = False
247
- self.models_created = []
248
-
249
- def stop(self):
250
- """Request the worker to stop."""
251
- self._should_stop = True
252
-
253
- def run(self):
254
- """Initialize multi-view SAM models in background thread."""
255
- try:
256
- if self._should_stop:
257
- return
258
-
259
- # Import the required model classes
260
- from ..models.sam_model import SamModel
261
-
262
- try:
263
- from ..models.sam2_model import Sam2Model
264
-
265
- SAM2_AVAILABLE = True
266
- except ImportError:
267
- Sam2Model = None
268
- SAM2_AVAILABLE = False
269
-
270
- # Determine which type of model to create
271
- # Get the currently selected model from the GUI
272
- parent = self.parent()
273
- custom_model_path = None
274
- default_model_type = "vit_h" # fallback
275
-
276
- if parent and hasattr(parent, "control_panel"):
277
- # Get the selected model path from the model selection widget
278
- model_path = parent.control_panel.model_widget.get_selected_model_path()
279
- if model_path:
280
- # User has selected a custom model
281
- custom_model_path = model_path
282
- # Detect model type from filename
283
- default_model_type = self.model_manager.detect_model_type(
284
- model_path
285
- )
286
- else:
287
- # Using default model
288
- default_model_type = (
289
- parent.settings.default_model_type
290
- if hasattr(parent, "settings")
291
- else "vit_h"
292
- )
293
-
294
- is_sam2 = default_model_type.startswith("sam2")
295
-
296
- # Create model instances for all viewers - but optimize memory usage
297
- config = parent._get_multi_view_config()
298
- num_viewers = config["num_viewers"]
299
-
300
- # Warn about performance implications for VIT_H in multi-view
301
- if num_viewers > 2 and default_model_type == "vit_h":
302
- logger.warning(
303
- f"Using vit_h model with {num_viewers} viewers may cause performance issues. Consider using vit_b for better performance."
304
- )
305
- for i in range(num_viewers):
306
- if self._should_stop:
307
- return
308
-
309
- self.progress.emit(i + 1, num_viewers)
310
-
311
- try:
312
- if is_sam2 and SAM2_AVAILABLE:
313
- # Create SAM2 model instance
314
- if custom_model_path:
315
- model_instance = Sam2Model(custom_model_path)
316
- else:
317
- model_instance = Sam2Model(model_type=default_model_type)
318
- else:
319
- # Create SAM1 model instance
320
- if custom_model_path:
321
- model_instance = SamModel(
322
- model_type=default_model_type,
323
- custom_model_path=custom_model_path,
324
- )
325
- else:
326
- model_instance = SamModel(model_type=default_model_type)
327
-
328
- if self._should_stop:
329
- return
330
-
331
- if model_instance and getattr(model_instance, "is_loaded", False):
332
- self.models_created.append(model_instance)
333
- self.model_initialized.emit(i, model_instance)
334
-
335
- # Synchronize and clear GPU cache after each model for stability
336
- try:
337
- import torch
338
-
339
- if torch.cuda.is_available():
340
- torch.cuda.synchronize() # Ensure model is fully loaded
341
- torch.cuda.empty_cache()
342
- except ImportError:
343
- pass # PyTorch not available
344
- else:
345
- raise Exception(f"Model instance {i + 1} failed to load")
346
-
347
- except Exception as model_error:
348
- logger.error(
349
- f"Error creating model instance {i + 1}: {model_error}"
350
- )
351
- if not self._should_stop:
352
- self.error.emit(
353
- f"Failed to create model instance {i + 1}: {model_error}"
354
- )
355
- return
356
-
357
- if not self._should_stop:
358
- self.all_models_initialized.emit(len(self.models_created))
359
-
360
- except Exception as e:
361
- if not self._should_stop:
362
- self.error.emit(str(e))
363
-
364
-
365
- class ImageDiscoveryWorker(QThread):
366
- """Worker thread for discovering all image file paths in background."""
367
-
368
- images_discovered = pyqtSignal(list) # List of all image file paths
369
- progress = pyqtSignal(str) # Progress message
370
- error = pyqtSignal(str)
371
-
372
- def __init__(self, file_model, file_manager, parent=None):
373
- super().__init__(parent)
374
- self.file_model = file_model
375
- self.file_manager = file_manager
376
- self._should_stop = False
377
-
378
- def stop(self):
379
- """Request the worker to stop."""
380
- self._should_stop = True
381
-
382
- def run(self):
383
- """Discover all image file paths in background."""
384
- try:
385
- if self._should_stop:
386
- return
387
-
388
- self.progress.emit("Scanning for images...")
389
-
390
- if (
391
- not hasattr(self.file_model, "rootPath")
392
- or not self.file_model.rootPath()
393
- ):
394
- self.images_discovered.emit([])
395
- return
396
-
397
- all_image_paths = []
398
- root_index = self.file_model.index(self.file_model.rootPath())
399
-
400
- def scan_directory(parent_index):
401
- if self._should_stop:
402
- return
403
-
404
- for row in range(self.file_model.rowCount(parent_index)):
405
- if self._should_stop:
406
- return
407
-
408
- index = self.file_model.index(row, 0, parent_index)
409
- if self.file_model.isDir(index):
410
- scan_directory(index) # Recursively scan subdirectories
411
- else:
412
- path = self.file_model.filePath(index)
413
- if self.file_manager.is_image_file(path):
414
- # Simply add all image file paths without checking for NPZ
415
- all_image_paths.append(path)
416
-
417
- scan_directory(root_index)
418
-
419
- if not self._should_stop:
420
- self.progress.emit(f"Found {len(all_image_paths)} images")
421
- self.images_discovered.emit(sorted(all_image_paths))
422
-
423
- except Exception as e:
424
- if not self._should_stop:
425
- self.error.emit(f"Error discovering images: {str(e)}")
426
-
427
-
428
- class MultiViewSAMUpdateWorker(QThread):
429
- """Worker thread for updating SAM model image in multi-view mode."""
430
-
431
- finished = pyqtSignal(int) # viewer_index
432
- error = pyqtSignal(int, str) # viewer_index, error_message
433
-
434
- def __init__(
435
- self,
436
- viewer_index,
437
- model,
438
- image_path,
439
- operate_on_view=False,
440
- current_image=None,
441
- parent=None,
442
- ):
443
- super().__init__(parent)
444
- self.viewer_index = viewer_index
445
- self.model = model
446
- self.image_path = image_path
447
- self.operate_on_view = operate_on_view
448
- self.current_image = current_image
449
- self._should_stop = False
450
- self.scale_factor = 1.0
451
-
452
- def stop(self):
453
- """Request the worker to stop."""
454
- self._should_stop = True
455
-
456
- def get_scale_factor(self):
457
- """Get the scale factor used for image resizing."""
458
- return self.scale_factor
459
-
460
- def run(self):
461
- """Update SAM model image in background thread."""
462
- try:
463
- if self._should_stop:
464
- return
465
-
466
- # Clear GPU cache to reduce memory pressure in multi-view mode
467
- try:
468
- import torch
469
-
470
- if torch.cuda.is_available():
471
- torch.cuda.empty_cache()
472
- except ImportError:
473
- pass # PyTorch not available
474
-
475
- if self.operate_on_view and self.current_image is not None:
476
- # Use the provided modified image
477
- if self._should_stop:
478
- return
479
-
480
- # Optimize image size for faster SAM processing
481
- image = self.current_image
482
- original_height, original_width = image.shape[:2]
483
- max_size = 1024
484
-
485
- if original_height > max_size or original_width > max_size:
486
- # Calculate scaling factor
487
- self.scale_factor = min(
488
- max_size / original_width, max_size / original_height
489
- )
490
- new_width = int(original_width * self.scale_factor)
491
- new_height = int(original_height * self.scale_factor)
492
-
493
- # Resize using OpenCV for speed
494
- image = cv2.resize(
495
- image, (new_width, new_height), interpolation=cv2.INTER_AREA
496
- )
497
- else:
498
- self.scale_factor = 1.0
499
-
500
- if self._should_stop:
501
- return
502
-
503
- # Set image from numpy array
504
- self.model.set_image_from_array(image)
505
- else:
506
- # Load original image
507
- if self._should_stop:
508
- return
509
-
510
- # Optimize image size for faster SAM processing
511
- pixmap = QPixmap(self.image_path)
512
- if pixmap.isNull():
513
- if not self._should_stop:
514
- self.error.emit(self.viewer_index, "Failed to load image")
515
- return
516
-
517
- original_width = pixmap.width()
518
- original_height = pixmap.height()
519
- max_size = 1024
520
-
521
- if original_width > max_size or original_height > max_size:
522
- # Calculate scaling factor
523
- self.scale_factor = min(
524
- max_size / original_width, max_size / original_height
525
- )
526
-
527
- # Scale down while maintaining aspect ratio
528
- scaled_pixmap = pixmap.scaled(
529
- max_size,
530
- max_size,
531
- Qt.AspectRatioMode.KeepAspectRatio,
532
- Qt.TransformationMode.SmoothTransformation,
533
- )
534
-
535
- # Convert to numpy array for SAM
536
- qimage = scaled_pixmap.toImage()
537
- width = qimage.width()
538
- height = qimage.height()
539
- ptr = qimage.bits()
540
- ptr.setsize(height * width * 4)
541
- arr = np.array(ptr).reshape(height, width, 4)
542
- # Convert RGBA to RGB
543
- image_array = arr[:, :, :3]
544
-
545
- if self._should_stop:
546
- return
547
-
548
- # Add CUDA synchronization for multi-model scenarios
549
- try:
550
- import torch
551
-
552
- if torch.cuda.is_available():
553
- torch.cuda.synchronize()
554
- except ImportError:
555
- pass
556
-
557
- self.model.set_image_from_array(image_array)
558
- else:
559
- self.scale_factor = 1.0
560
-
561
- # Add CUDA synchronization for multi-model scenarios
562
- try:
563
- import torch
564
-
565
- if torch.cuda.is_available():
566
- torch.cuda.synchronize()
567
- except ImportError:
568
- pass
569
-
570
- self.model.set_image_from_path(self.image_path)
571
-
572
- if not self._should_stop:
573
- self.finished.emit(self.viewer_index)
574
-
575
- except Exception as e:
576
- if not self._should_stop:
577
- self.error.emit(self.viewer_index, str(e))
57
+ from .workers import (
58
+ ImageDiscoveryWorker,
59
+ MultiViewSAMInitWorker,
60
+ MultiViewSAMUpdateWorker,
61
+ SAMUpdateWorker,
62
+ SingleViewSAMInitWorker,
63
+ )
578
64
 
579
65
 
580
66
  class PanelPopoutWindow(QDialog):
@@ -772,12 +258,32 @@ class MainWindow(QMainWindow):
772
258
  logger.info("Step 5/8: Discovering available models...")
773
259
  self._setup_model_manager() # Just setup manager, don't load model
774
260
  self._setup_connections()
261
+ self._fix_fft_connection() # Workaround for FFT signal connection issue
775
262
  self._setup_shortcuts()
776
263
  self._load_settings()
777
264
 
265
+ def _get_version(self) -> str:
266
+ """Get version from pyproject.toml."""
267
+ try:
268
+ # Get the project root directory (3 levels up from main_window.py)
269
+ project_root = Path(__file__).parent.parent.parent.parent
270
+ pyproject_path = project_root / "pyproject.toml"
271
+
272
+ if pyproject_path.exists():
273
+ with open(pyproject_path, encoding="utf-8") as f:
274
+ content = f.read()
275
+ # Use regex to find version line
276
+ match = re.search(r'version\s*=\s*"([^"]+)"', content)
277
+ if match:
278
+ return match.group(1)
279
+ except Exception:
280
+ pass
281
+ return "unknown"
282
+
778
283
  def _setup_ui(self):
779
284
  """Setup the main user interface."""
780
- self.setWindowTitle("LazyLabel by DNC")
285
+ version = self._get_version()
286
+ self.setWindowTitle(f"LazyLabel by DNC (version {version})")
781
287
  self.setGeometry(
782
288
  50, 50, self.settings.window_width, self.settings.window_height
783
289
  )
@@ -877,12 +383,7 @@ class MainWindow(QMainWindow):
877
383
  grid_widget = QWidget()
878
384
 
879
385
  # Use grid layout for 4-view, horizontal layout for 2-view
880
- if use_grid:
881
- from PyQt6.QtWidgets import QGridLayout
882
-
883
- grid_layout = QGridLayout(grid_widget)
884
- else:
885
- grid_layout = QHBoxLayout(grid_widget)
386
+ grid_layout = QGridLayout(grid_widget) if use_grid else QHBoxLayout(grid_widget)
886
387
  grid_layout.setSpacing(5)
887
388
 
888
389
  self.multi_view_viewers = []
@@ -951,29 +452,30 @@ class MainWindow(QMainWindow):
951
452
  grid_mode_label = QLabel("View Mode:")
952
453
  controls_layout.addWidget(grid_mode_label)
953
454
 
954
- from PyQt6.QtWidgets import QComboBox
455
+ from lazylabel.ui.widgets.model_selection_widget import CustomDropdown
955
456
 
956
- self.grid_mode_combo = QComboBox()
457
+ self.grid_mode_combo = CustomDropdown()
458
+ self.grid_mode_combo.setText("View Mode") # Default text
957
459
  self.grid_mode_combo.addItem("2 Views (1x2)", "2_view")
958
460
  self.grid_mode_combo.addItem("4 Views (2x2)", "4_view")
959
461
 
960
462
  # Set current selection based on settings
961
463
  current_mode = self.settings.multi_view_grid_mode
962
- for i in range(self.grid_mode_combo.count()):
464
+ for i in range(len(self.grid_mode_combo.items)):
963
465
  if self.grid_mode_combo.itemData(i) == current_mode:
964
466
  self.grid_mode_combo.setCurrentIndex(i)
965
467
  break
966
468
 
967
- self.grid_mode_combo.currentTextChanged.connect(self._on_grid_mode_changed)
469
+ self.grid_mode_combo.activated.connect(self._on_grid_mode_changed)
968
470
  controls_layout.addWidget(self.grid_mode_combo)
969
471
 
970
472
  controls_layout.addStretch()
971
473
 
972
474
  layout.addWidget(controls_widget)
973
475
 
974
- def _on_grid_mode_changed(self):
476
+ def _on_grid_mode_changed(self, index):
975
477
  """Handle grid mode change from combo box."""
976
- current_data = self.grid_mode_combo.currentData()
478
+ current_data = self.grid_mode_combo.itemData(index)
977
479
  if current_data and current_data != self.settings.multi_view_grid_mode:
978
480
  # Update settings
979
481
  self.settings.multi_view_grid_mode = current_data
@@ -1062,8 +564,7 @@ class MainWindow(QMainWindow):
1062
564
  layout.deleteLater()
1063
565
  except Exception as e:
1064
566
  # If layout clearing fails, just reset the viewer lists
1065
- print(f"Layout clearing failed: {e}")
1066
- pass
567
+ logger.error(f"Layout clearing failed: {e}")
1067
568
 
1068
569
  def _clear_multi_view_layout(self):
1069
570
  """Clear the existing multi-view layout."""
@@ -1121,6 +622,48 @@ class MainWindow(QMainWindow):
1121
622
  # Switch to polygon mode if SAM is disabled and we're in SAM/AI mode
1122
623
  self.set_polygon_mode()
1123
624
 
625
+ def _fix_fft_connection(self):
626
+ """Fix FFT signal connection issue - workaround for connection timing problem."""
627
+ try:
628
+ # Get the FFT widget directly and connect to its signal
629
+ fft_widget = self.control_panel.get_fft_threshold_widget()
630
+ if fft_widget:
631
+ # Direct connection bypass - connect FFT widget directly to main window handler
632
+ # This bypasses the control panel signal forwarding which has timing issues
633
+ # Use a wrapper to ensure the connection works reliably
634
+ def fft_signal_wrapper():
635
+ self._handle_fft_threshold_changed()
636
+
637
+ fft_widget.fft_threshold_changed.connect(fft_signal_wrapper)
638
+
639
+ logger.info("FFT signal connection bypass established successfully")
640
+ else:
641
+ logger.warning("FFT widget not found during connection fix")
642
+ except Exception as e:
643
+ logger.warning(f"Failed to establish FFT connection bypass: {e}")
644
+
645
+ # Also fix channel threshold connection for RGB images
646
+ try:
647
+ channel_widget = self.control_panel.get_channel_threshold_widget()
648
+ if channel_widget:
649
+ # Direct connection bypass for channel threshold widget too
650
+ def channel_signal_wrapper():
651
+ self._handle_channel_threshold_changed()
652
+
653
+ channel_widget.thresholdChanged.connect(channel_signal_wrapper)
654
+
655
+ logger.info(
656
+ "Channel threshold signal connection bypass established successfully"
657
+ )
658
+ else:
659
+ logger.warning(
660
+ "Channel threshold widget not found during connection fix"
661
+ )
662
+ except Exception as e:
663
+ logger.warning(
664
+ f"Failed to establish channel threshold connection bypass: {e}"
665
+ )
666
+
1124
667
  def _setup_connections(self):
1125
668
  """Setup signal connections."""
1126
669
  # Control panel connections
@@ -1169,9 +712,13 @@ class MainWindow(QMainWindow):
1169
712
  )
1170
713
 
1171
714
  # FFT threshold connections
1172
- self.control_panel.fft_threshold_changed.connect(
1173
- self._handle_fft_threshold_changed
1174
- )
715
+ try:
716
+ self.control_panel.fft_threshold_changed.connect(
717
+ self._handle_fft_threshold_changed
718
+ )
719
+ logger.debug("FFT threshold connection established in _setup_connections")
720
+ except Exception as e:
721
+ logger.error(f"Failed to establish FFT threshold connection: {e}")
1175
722
 
1176
723
  # Right panel connections
1177
724
  self.right_panel.open_folder_requested.connect(self._open_folder_dialog)
@@ -1466,7 +1013,7 @@ class MainWindow(QMainWindow):
1466
1013
  if not model_text or model_text == "Default (vit_h)":
1467
1014
  # Clear any pending custom model and use default
1468
1015
  self.pending_custom_model_path = None
1469
- self.control_panel.set_current_model("Current: Default SAM Model")
1016
+ self.control_panel.set_current_model("Selected: Default SAM Model")
1470
1017
  # Clear existing model to free memory until needed
1471
1018
  self._reset_sam_state_for_model_switch()
1472
1019
  return
@@ -1659,7 +1206,17 @@ class MainWindow(QMainWindow):
1659
1206
  self._save_output_to_npz()
1660
1207
 
1661
1208
  self.current_image_path = path
1662
- pixmap = QPixmap(self.current_image_path)
1209
+ # Load image with explicit transparency support
1210
+ qimage = QImage(self.current_image_path)
1211
+ if qimage.isNull():
1212
+ return
1213
+ # For PNG files, always ensure proper alpha format handling
1214
+ if self.current_image_path.lower().endswith(".png"):
1215
+ # PNG files can have alpha channels, use ARGB32_Premultiplied for proper handling
1216
+ qimage = qimage.convertToFormat(
1217
+ QImage.Format.Format_ARGB32_Premultiplied
1218
+ )
1219
+ pixmap = QPixmap.fromImage(qimage)
1663
1220
  if not pixmap.isNull():
1664
1221
  self._reset_state()
1665
1222
  self.viewer.set_photo(pixmap)
@@ -1713,6 +1270,11 @@ class MainWindow(QMainWindow):
1713
1270
  ):
1714
1271
  self._save_output_to_npz()
1715
1272
 
1273
+ self.current_image_path = path
1274
+
1275
+ # CRITICAL: Reset state and mark SAM as dirty when loading new image
1276
+ self._reset_state()
1277
+
1716
1278
  self.segment_manager.clear()
1717
1279
  # Remove all scene items except the pixmap
1718
1280
  items_to_remove = [
@@ -1722,7 +1284,6 @@ class MainWindow(QMainWindow):
1722
1284
  ]
1723
1285
  for item in items_to_remove:
1724
1286
  self.viewer.scene().removeItem(item)
1725
- self.current_image_path = path
1726
1287
 
1727
1288
  # Load the image
1728
1289
  original_image = cv2.imread(path)
@@ -1774,6 +1335,12 @@ class MainWindow(QMainWindow):
1774
1335
  # Update file selection in the file manager
1775
1336
  self.right_panel.select_file(Path(path))
1776
1337
 
1338
+ # CRITICAL: Update SAM model with new image
1339
+ self._update_sam_model_image()
1340
+
1341
+ # Update threshold widgets for new image (this was missing!)
1342
+ self._update_channel_threshold_for_image(pixmap)
1343
+
1777
1344
  def _load_multi_view_from_path(self, path: str):
1778
1345
  """Load multi-view starting from a specific path using FastFileManager."""
1779
1346
  config = self._get_multi_view_config()
@@ -1897,7 +1464,21 @@ class MainWindow(QMainWindow):
1897
1464
  image_path = self.multi_view_images[i]
1898
1465
 
1899
1466
  if image_path:
1900
- pixmap = QPixmap(image_path)
1467
+ # Load image with explicit transparency support
1468
+ qimage = QImage(image_path)
1469
+ if qimage.isNull():
1470
+ self.multi_view_viewers[i].set_photo(QPixmap())
1471
+ self.multi_view_info_labels[i].setText(
1472
+ f"Image {i + 1}: Failed to load"
1473
+ )
1474
+ continue
1475
+ # For PNG files, always ensure proper alpha format handling
1476
+ if image_path.lower().endswith(".png"):
1477
+ # PNG files can have alpha channels, use ARGB32_Premultiplied for proper handling
1478
+ qimage = qimage.convertToFormat(
1479
+ QImage.Format.Format_ARGB32_Premultiplied
1480
+ )
1481
+ pixmap = QPixmap.fromImage(qimage)
1901
1482
  if not pixmap.isNull():
1902
1483
  self.multi_view_viewers[i].set_photo(pixmap)
1903
1484
  # Apply current image adjustments to the newly loaded image
@@ -1948,6 +1529,9 @@ class MainWindow(QMainWindow):
1948
1529
  if changed_indices:
1949
1530
  self._fast_update_multi_view_images(changed_indices)
1950
1531
 
1532
+ # Update threshold widgets for the loaded images
1533
+ self._update_multi_view_channel_threshold_for_images()
1534
+
1951
1535
  # Load existing segments for all loaded images
1952
1536
  valid_image_paths = [path for path in image_paths if path is not None]
1953
1537
  if valid_image_paths:
@@ -1983,6 +1567,21 @@ class MainWindow(QMainWindow):
1983
1567
  self.action_history.clear()
1984
1568
  self.redo_history.clear()
1985
1569
 
1570
+ # Reset SAM model state - force reload for new images (same as single view)
1571
+ self.current_sam_hash = None # Invalidate SAM cache
1572
+ self.sam_is_dirty = True # Mark SAM as needing update
1573
+
1574
+ # Clear cached image data to prevent using previous images
1575
+ self._cached_original_image = None
1576
+ if hasattr(self, "_cached_multi_view_original_images"):
1577
+ self._cached_multi_view_original_images = None
1578
+
1579
+ # Clear SAM embedding cache to ensure fresh processing
1580
+ self.sam_embedding_cache.clear()
1581
+
1582
+ # Reset AI mode state
1583
+ self.ai_click_start_pos = None
1584
+
1986
1585
  # Update UI lists to reflect cleared state
1987
1586
  self._update_all_lists()
1988
1587
 
@@ -2005,7 +1604,9 @@ class MainWindow(QMainWindow):
2005
1604
  all_segments.extend(viewer_segments)
2006
1605
  self.segment_manager.segments.clear()
2007
1606
  except Exception as e:
2008
- print(f"Error loading segments for viewer {viewer_index}: {e}")
1607
+ logger.error(
1608
+ f"Error loading segments for viewer {viewer_index}: {e}"
1609
+ )
2009
1610
 
2010
1611
  # Set all segments at once
2011
1612
  self.segment_manager.segments = all_segments
@@ -2025,7 +1626,7 @@ class MainWindow(QMainWindow):
2025
1626
  self._update_all_lists()
2026
1627
 
2027
1628
  except Exception as e:
2028
- print(f"Error in _load_multi_view_segments: {e}")
1629
+ logger.error(f"Error in _load_multi_view_segments: {e}")
2029
1630
  self.segment_manager.segments.clear()
2030
1631
 
2031
1632
  def _cancel_multi_view_sam_loading(self):
@@ -2245,9 +1846,18 @@ class MainWindow(QMainWindow):
2245
1846
  # Convert from BGRA to RGB for SAM
2246
1847
  image_rgb = cv2.cvtColor(image_np, cv2.COLOR_BGRA2RGB)
2247
1848
  self.model_manager.sam_model.set_image_from_array(image_rgb)
1849
+ # Update hash to prevent worker thread from re-updating
1850
+ image_hash = self._get_image_hash(image_rgb)
1851
+ self.current_sam_hash = image_hash
2248
1852
  else:
2249
1853
  # Pass the original image path to SAM model
2250
1854
  self.model_manager.sam_model.set_image_from_path(self.current_image_path)
1855
+ # Update hash to prevent worker thread from re-updating
1856
+ image_hash = hashlib.md5(self.current_image_path.encode()).hexdigest()
1857
+ self.current_sam_hash = image_hash
1858
+
1859
+ # Mark SAM as clean since we just updated it
1860
+ self.sam_is_dirty = False
2251
1861
 
2252
1862
  def _load_next_image(self):
2253
1863
  """Load next image in the file list."""
@@ -3353,6 +2963,8 @@ class MainWindow(QMainWindow):
3353
2963
  valid_paths = [Path(img) for img in self.multi_view_images if img]
3354
2964
  if valid_paths:
3355
2965
  self.right_panel.file_manager.batchUpdateFileStatus(valid_paths)
2966
+ # Force immediate GUI update
2967
+ QApplication.processEvents()
3356
2968
  # Clear the tracking list for next save
3357
2969
  self._saved_file_paths = []
3358
2970
 
@@ -3401,6 +3013,8 @@ class MainWindow(QMainWindow):
3401
3013
  )
3402
3014
  # Update UI immediately when files are deleted
3403
3015
  self._update_all_lists()
3016
+ # Force immediate GUI update
3017
+ QApplication.processEvents()
3404
3018
  else:
3405
3019
  self._show_warning_notification("No segments to save.")
3406
3020
  return
@@ -3478,15 +3092,16 @@ class MainWindow(QMainWindow):
3478
3092
  )
3479
3093
 
3480
3094
  # Update FastFileManager to show NPZ/TXT checkmarks
3481
- if (
3482
- (npz_path or txt_path)
3483
- and hasattr(self, "right_panel")
3484
- and hasattr(self.right_panel, "file_manager")
3095
+ # Always update file status after save attempt (regardless of what was saved)
3096
+ if hasattr(self, "right_panel") and hasattr(
3097
+ self.right_panel, "file_manager"
3485
3098
  ):
3486
3099
  # Update the file status in the FastFileManager
3487
3100
  self.right_panel.file_manager.updateFileStatus(
3488
3101
  Path(self.current_image_path)
3489
3102
  )
3103
+ # Force immediate GUI update
3104
+ QApplication.processEvents()
3490
3105
  except Exception as e:
3491
3106
  logger.error(f"Error saving file: {str(e)}", exc_info=True)
3492
3107
  self._show_error_notification(f"Error saving: {str(e)}")
@@ -4179,6 +3794,18 @@ class MainWindow(QMainWindow):
4179
3794
  self.crop_start_pos = None
4180
3795
  self.current_crop_coords = None
4181
3796
 
3797
+ # Reset SAM model state - force reload for new image
3798
+ self.current_sam_hash = None # Invalidate SAM cache
3799
+ self.sam_is_dirty = True # Mark SAM as needing update
3800
+
3801
+ # Clear cached image data to prevent using previous image
3802
+ self._cached_original_image = None
3803
+ if hasattr(self, "_cached_multi_view_original_images"):
3804
+ self._cached_multi_view_original_images = None
3805
+
3806
+ # Clear SAM embedding cache to ensure fresh processing
3807
+ self.sam_embedding_cache.clear()
3808
+
4182
3809
  # Reset AI mode state
4183
3810
  self.ai_click_start_pos = None
4184
3811
  self.ai_click_time = 0
@@ -5714,27 +5341,50 @@ class MainWindow(QMainWindow):
5714
5341
 
5715
5342
  def _update_channel_threshold_for_image(self, pixmap):
5716
5343
  """Update channel threshold widget for the given image pixmap."""
5717
- if pixmap.isNull():
5344
+ if pixmap.isNull() or not self.current_image_path:
5718
5345
  self.control_panel.update_channel_threshold_for_image(None)
5719
5346
  return
5720
5347
 
5721
- # Convert pixmap to numpy array
5722
- qimage = pixmap.toImage()
5723
- ptr = qimage.constBits()
5724
- ptr.setsize(qimage.bytesPerLine() * qimage.height())
5725
- image_np = np.array(ptr).reshape(qimage.height(), qimage.width(), 4)
5726
- # Convert from BGRA to RGB, ignore alpha
5727
- image_rgb = image_np[:, :, [2, 1, 0]]
5348
+ # Use cv2.imread for more robust loading instead of QPixmap conversion
5349
+ try:
5350
+ import cv2
5728
5351
 
5729
- # Check if image is grayscale (all channels are the same)
5730
- if np.array_equal(image_rgb[:, :, 0], image_rgb[:, :, 1]) and np.array_equal(
5731
- image_rgb[:, :, 1], image_rgb[:, :, 2]
5732
- ):
5733
- # Convert to single channel grayscale
5734
- image_array = image_rgb[:, :, 0]
5735
- else:
5736
- # Keep as RGB
5737
- image_array = image_rgb
5352
+ image_array = cv2.imread(self.current_image_path)
5353
+ if image_array is None:
5354
+ self.control_panel.update_channel_threshold_for_image(None)
5355
+ return
5356
+
5357
+ # Convert from BGR to RGB
5358
+ if len(image_array.shape) == 3 and image_array.shape[2] == 3:
5359
+ image_array = cv2.cvtColor(image_array, cv2.COLOR_BGR2RGB)
5360
+
5361
+ # Check if image is grayscale (all channels are the same)
5362
+ if (
5363
+ len(image_array.shape) == 3
5364
+ and np.array_equal(image_array[:, :, 0], image_array[:, :, 1])
5365
+ and np.array_equal(image_array[:, :, 1], image_array[:, :, 2])
5366
+ ):
5367
+ # Convert to single channel grayscale
5368
+ image_array = image_array[:, :, 0]
5369
+
5370
+ except Exception:
5371
+ # Fallback to QPixmap conversion if cv2 fails
5372
+ qimage = pixmap.toImage()
5373
+ ptr = qimage.constBits()
5374
+ ptr.setsize(qimage.bytesPerLine() * qimage.height())
5375
+ image_np = np.array(ptr).reshape(qimage.height(), qimage.width(), 4)
5376
+ # Convert from BGRA to RGB, ignore alpha
5377
+ image_rgb = image_np[:, :, [2, 1, 0]]
5378
+
5379
+ # Check if image is grayscale (all channels are the same)
5380
+ if np.array_equal(
5381
+ image_rgb[:, :, 0], image_rgb[:, :, 1]
5382
+ ) and np.array_equal(image_rgb[:, :, 1], image_rgb[:, :, 2]):
5383
+ # Convert to single channel grayscale
5384
+ image_array = image_rgb[:, :, 0]
5385
+ else:
5386
+ # Keep as RGB
5387
+ image_array = image_rgb
5738
5388
 
5739
5389
  # Update the channel threshold widget
5740
5390
  self.control_panel.update_channel_threshold_for_image(image_array)
@@ -5762,29 +5412,51 @@ class MainWindow(QMainWindow):
5762
5412
  self.control_panel.update_channel_threshold_for_image(None)
5763
5413
  return
5764
5414
 
5765
- # Load and convert the first image to update the widget
5766
- pixmap = QPixmap(first_image_path)
5767
- if pixmap.isNull():
5768
- self.control_panel.update_channel_threshold_for_image(None)
5769
- return
5415
+ # Use cv2.imread for more robust loading instead of QPixmap conversion
5416
+ try:
5417
+ import cv2
5770
5418
 
5771
- # Convert pixmap to numpy array
5772
- qimage = pixmap.toImage()
5773
- ptr = qimage.constBits()
5774
- ptr.setsize(qimage.bytesPerLine() * qimage.height())
5775
- image_np = np.array(ptr).reshape(qimage.height(), qimage.width(), 4)
5776
- # Convert from BGRA to RGB, ignore alpha
5777
- image_rgb = image_np[:, :, [2, 1, 0]]
5419
+ image_array = cv2.imread(first_image_path)
5420
+ if image_array is None:
5421
+ self.control_panel.update_channel_threshold_for_image(None)
5422
+ return
5778
5423
 
5779
- # Check if image is grayscale (all channels are the same)
5780
- if np.array_equal(image_rgb[:, :, 0], image_rgb[:, :, 1]) and np.array_equal(
5781
- image_rgb[:, :, 1], image_rgb[:, :, 2]
5782
- ):
5783
- # Convert to single channel grayscale
5784
- image_array = image_rgb[:, :, 0]
5785
- else:
5786
- # Keep as RGB
5787
- image_array = image_rgb
5424
+ # Convert from BGR to RGB
5425
+ if len(image_array.shape) == 3 and image_array.shape[2] == 3:
5426
+ image_array = cv2.cvtColor(image_array, cv2.COLOR_BGR2RGB)
5427
+
5428
+ # Check if image is grayscale (all channels are the same)
5429
+ if (
5430
+ len(image_array.shape) == 3
5431
+ and np.array_equal(image_array[:, :, 0], image_array[:, :, 1])
5432
+ and np.array_equal(image_array[:, :, 1], image_array[:, :, 2])
5433
+ ):
5434
+ # Convert to single channel grayscale
5435
+ image_array = image_array[:, :, 0]
5436
+
5437
+ except Exception:
5438
+ # Fallback to QPixmap conversion if cv2 fails
5439
+ pixmap = QPixmap(first_image_path)
5440
+ if pixmap.isNull():
5441
+ self.control_panel.update_channel_threshold_for_image(None)
5442
+ return
5443
+
5444
+ qimage = pixmap.toImage()
5445
+ ptr = qimage.constBits()
5446
+ ptr.setsize(qimage.bytesPerLine() * qimage.height())
5447
+ image_np = np.array(ptr).reshape(qimage.height(), qimage.width(), 4)
5448
+ # Convert from BGRA to RGB, ignore alpha
5449
+ image_rgb = image_np[:, :, [2, 1, 0]]
5450
+
5451
+ # Check if image is grayscale (all channels are the same)
5452
+ if np.array_equal(
5453
+ image_rgb[:, :, 0], image_rgb[:, :, 1]
5454
+ ) and np.array_equal(image_rgb[:, :, 1], image_rgb[:, :, 2]):
5455
+ # Convert to single channel grayscale
5456
+ image_array = image_rgb[:, :, 0]
5457
+ else:
5458
+ # Keep as RGB
5459
+ image_array = image_rgb
5788
5460
 
5789
5461
  # Update the channel threshold widget
5790
5462
  self.control_panel.update_channel_threshold_for_image(image_array)
@@ -7097,7 +6769,6 @@ class MainWindow(QMainWindow):
7097
6769
  )
7098
6770
 
7099
6771
  # Load existing segments for the current image
7100
- print(f"Loading segments for single-view: {self.current_image_path}")
7101
6772
  try:
7102
6773
  # Clear any leftover multi-view segments first
7103
6774
  self.segment_manager.clear()
@@ -7114,7 +6785,7 @@ class MainWindow(QMainWindow):
7114
6785
  self._update_all_lists()
7115
6786
 
7116
6787
  except Exception as e:
7117
- print(f"Error loading segments for single-view: {e}")
6788
+ logger.error(f"Error loading segments for single-view: {e}")
7118
6789
 
7119
6790
  # Redisplay segments for single view
7120
6791
  if hasattr(self, "single_view_mode_handler"):