lazylabel-gui 1.3.2__py3-none-any.whl → 1.3.4__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.
@@ -19,12 +19,14 @@ from PyQt6.QtGui import (
19
19
  QShortcut,
20
20
  )
21
21
  from PyQt6.QtWidgets import (
22
+ QApplication,
22
23
  QDialog,
23
24
  QFileDialog,
24
25
  QGraphicsEllipseItem,
25
26
  QGraphicsLineItem,
26
27
  QGraphicsPolygonItem,
27
28
  QGraphicsRectItem,
29
+ QGridLayout,
28
30
  QHBoxLayout,
29
31
  QLabel,
30
32
  QMainWindow,
@@ -772,6 +774,7 @@ class MainWindow(QMainWindow):
772
774
  logger.info("Step 5/8: Discovering available models...")
773
775
  self._setup_model_manager() # Just setup manager, don't load model
774
776
  self._setup_connections()
777
+ self._fix_fft_connection() # Workaround for FFT signal connection issue
775
778
  self._setup_shortcuts()
776
779
  self._load_settings()
777
780
 
@@ -877,12 +880,7 @@ class MainWindow(QMainWindow):
877
880
  grid_widget = QWidget()
878
881
 
879
882
  # 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)
883
+ grid_layout = QGridLayout(grid_widget) if use_grid else QHBoxLayout(grid_widget)
886
884
  grid_layout.setSpacing(5)
887
885
 
888
886
  self.multi_view_viewers = []
@@ -951,29 +949,30 @@ class MainWindow(QMainWindow):
951
949
  grid_mode_label = QLabel("View Mode:")
952
950
  controls_layout.addWidget(grid_mode_label)
953
951
 
954
- from PyQt6.QtWidgets import QComboBox
952
+ from lazylabel.ui.widgets.model_selection_widget import CustomDropdown
955
953
 
956
- self.grid_mode_combo = QComboBox()
954
+ self.grid_mode_combo = CustomDropdown()
955
+ self.grid_mode_combo.setText("View Mode") # Default text
957
956
  self.grid_mode_combo.addItem("2 Views (1x2)", "2_view")
958
957
  self.grid_mode_combo.addItem("4 Views (2x2)", "4_view")
959
958
 
960
959
  # Set current selection based on settings
961
960
  current_mode = self.settings.multi_view_grid_mode
962
- for i in range(self.grid_mode_combo.count()):
961
+ for i in range(len(self.grid_mode_combo.items)):
963
962
  if self.grid_mode_combo.itemData(i) == current_mode:
964
963
  self.grid_mode_combo.setCurrentIndex(i)
965
964
  break
966
965
 
967
- self.grid_mode_combo.currentTextChanged.connect(self._on_grid_mode_changed)
966
+ self.grid_mode_combo.activated.connect(self._on_grid_mode_changed)
968
967
  controls_layout.addWidget(self.grid_mode_combo)
969
968
 
970
969
  controls_layout.addStretch()
971
970
 
972
971
  layout.addWidget(controls_widget)
973
972
 
974
- def _on_grid_mode_changed(self):
973
+ def _on_grid_mode_changed(self, index):
975
974
  """Handle grid mode change from combo box."""
976
- current_data = self.grid_mode_combo.currentData()
975
+ current_data = self.grid_mode_combo.itemData(index)
977
976
  if current_data and current_data != self.settings.multi_view_grid_mode:
978
977
  # Update settings
979
978
  self.settings.multi_view_grid_mode = current_data
@@ -1062,8 +1061,7 @@ class MainWindow(QMainWindow):
1062
1061
  layout.deleteLater()
1063
1062
  except Exception as e:
1064
1063
  # If layout clearing fails, just reset the viewer lists
1065
- print(f"Layout clearing failed: {e}")
1066
- pass
1064
+ logger.error(f"Layout clearing failed: {e}")
1067
1065
 
1068
1066
  def _clear_multi_view_layout(self):
1069
1067
  """Clear the existing multi-view layout."""
@@ -1121,6 +1119,48 @@ class MainWindow(QMainWindow):
1121
1119
  # Switch to polygon mode if SAM is disabled and we're in SAM/AI mode
1122
1120
  self.set_polygon_mode()
1123
1121
 
1122
+ def _fix_fft_connection(self):
1123
+ """Fix FFT signal connection issue - workaround for connection timing problem."""
1124
+ try:
1125
+ # Get the FFT widget directly and connect to its signal
1126
+ fft_widget = self.control_panel.get_fft_threshold_widget()
1127
+ if fft_widget:
1128
+ # Direct connection bypass - connect FFT widget directly to main window handler
1129
+ # This bypasses the control panel signal forwarding which has timing issues
1130
+ # Use a wrapper to ensure the connection works reliably
1131
+ def fft_signal_wrapper():
1132
+ self._handle_fft_threshold_changed()
1133
+
1134
+ fft_widget.fft_threshold_changed.connect(fft_signal_wrapper)
1135
+
1136
+ logger.info("FFT signal connection bypass established successfully")
1137
+ else:
1138
+ logger.warning("FFT widget not found during connection fix")
1139
+ except Exception as e:
1140
+ logger.warning(f"Failed to establish FFT connection bypass: {e}")
1141
+
1142
+ # Also fix channel threshold connection for RGB images
1143
+ try:
1144
+ channel_widget = self.control_panel.get_channel_threshold_widget()
1145
+ if channel_widget:
1146
+ # Direct connection bypass for channel threshold widget too
1147
+ def channel_signal_wrapper():
1148
+ self._handle_channel_threshold_changed()
1149
+
1150
+ channel_widget.thresholdChanged.connect(channel_signal_wrapper)
1151
+
1152
+ logger.info(
1153
+ "Channel threshold signal connection bypass established successfully"
1154
+ )
1155
+ else:
1156
+ logger.warning(
1157
+ "Channel threshold widget not found during connection fix"
1158
+ )
1159
+ except Exception as e:
1160
+ logger.warning(
1161
+ f"Failed to establish channel threshold connection bypass: {e}"
1162
+ )
1163
+
1124
1164
  def _setup_connections(self):
1125
1165
  """Setup signal connections."""
1126
1166
  # Control panel connections
@@ -1169,9 +1209,13 @@ class MainWindow(QMainWindow):
1169
1209
  )
1170
1210
 
1171
1211
  # FFT threshold connections
1172
- self.control_panel.fft_threshold_changed.connect(
1173
- self._handle_fft_threshold_changed
1174
- )
1212
+ try:
1213
+ self.control_panel.fft_threshold_changed.connect(
1214
+ self._handle_fft_threshold_changed
1215
+ )
1216
+ logger.debug("FFT threshold connection established in _setup_connections")
1217
+ except Exception as e:
1218
+ logger.error(f"Failed to establish FFT threshold connection: {e}")
1175
1219
 
1176
1220
  # Right panel connections
1177
1221
  self.right_panel.open_folder_requested.connect(self._open_folder_dialog)
@@ -1774,6 +1818,9 @@ class MainWindow(QMainWindow):
1774
1818
  # Update file selection in the file manager
1775
1819
  self.right_panel.select_file(Path(path))
1776
1820
 
1821
+ # Update threshold widgets for new image (this was missing!)
1822
+ self._update_channel_threshold_for_image(pixmap)
1823
+
1777
1824
  def _load_multi_view_from_path(self, path: str):
1778
1825
  """Load multi-view starting from a specific path using FastFileManager."""
1779
1826
  config = self._get_multi_view_config()
@@ -1788,31 +1835,12 @@ class MainWindow(QMainWindow):
1788
1835
  ):
1789
1836
  self._save_multi_view_output()
1790
1837
 
1791
- # Get all files from the FastFileManager model
1792
- file_manager_model = self.right_panel.file_manager._model
1793
- all_files = []
1794
- current_idx = -1
1838
+ # Get surrounding files in current sorted/filtered order
1839
+ file_manager = self.right_panel.file_manager
1840
+ surrounding_files = file_manager.getSurroundingFiles(Path(path), num_viewers)
1795
1841
 
1796
- # Collect all files from the model
1797
- for i in range(file_manager_model.rowCount()):
1798
- file_info = file_manager_model.getFileInfo(i)
1799
- if file_info:
1800
- all_files.append(str(file_info.path))
1801
- if str(file_info.path) == path:
1802
- current_idx = i
1803
-
1804
- if current_idx == -1:
1805
- # File not found in current list
1806
- return
1807
-
1808
- # Load consecutive images
1809
- images_to_load = []
1810
- for i in range(num_viewers):
1811
- idx = current_idx + i
1812
- if idx < len(all_files):
1813
- images_to_load.append(all_files[idx])
1814
- else:
1815
- images_to_load.append(None)
1842
+ # Convert to strings for loading
1843
+ images_to_load = [str(p) if p else None for p in surrounding_files]
1816
1844
 
1817
1845
  # Load the images
1818
1846
  self._load_multi_view_images(images_to_load)
@@ -1967,6 +1995,9 @@ class MainWindow(QMainWindow):
1967
1995
  if changed_indices:
1968
1996
  self._fast_update_multi_view_images(changed_indices)
1969
1997
 
1998
+ # Update threshold widgets for the loaded images
1999
+ self._update_multi_view_channel_threshold_for_images()
2000
+
1970
2001
  # Load existing segments for all loaded images
1971
2002
  valid_image_paths = [path for path in image_paths if path is not None]
1972
2003
  if valid_image_paths:
@@ -2024,7 +2055,9 @@ class MainWindow(QMainWindow):
2024
2055
  all_segments.extend(viewer_segments)
2025
2056
  self.segment_manager.segments.clear()
2026
2057
  except Exception as e:
2027
- print(f"Error loading segments for viewer {viewer_index}: {e}")
2058
+ logger.error(
2059
+ f"Error loading segments for viewer {viewer_index}: {e}"
2060
+ )
2028
2061
 
2029
2062
  # Set all segments at once
2030
2063
  self.segment_manager.segments = all_segments
@@ -2044,7 +2077,7 @@ class MainWindow(QMainWindow):
2044
2077
  self._update_all_lists()
2045
2078
 
2046
2079
  except Exception as e:
2047
- print(f"Error in _load_multi_view_segments: {e}")
2080
+ logger.error(f"Error in _load_multi_view_segments: {e}")
2048
2081
  self.segment_manager.segments.clear()
2049
2082
 
2050
2083
  def _cancel_multi_view_sam_loading(self):
@@ -3320,6 +3353,18 @@ class MainWindow(QMainWindow):
3320
3353
  # Restore original segments
3321
3354
  self.segment_manager.segments = original_segments
3322
3355
 
3356
+ # Save class aliases if enabled
3357
+ if settings.get("save_class_aliases", False) and viewer_segments:
3358
+ # Temporarily set segments for this viewer to save correct aliases
3359
+ original_segments = self.segment_manager.segments
3360
+ self.segment_manager.segments = viewer_segments
3361
+
3362
+ aliases_path = self.file_manager.save_class_aliases(image_path)
3363
+ saved_files.append(os.path.basename(aliases_path))
3364
+
3365
+ # Restore original segments
3366
+ self.segment_manager.segments = original_segments
3367
+
3323
3368
  # If no segments for this viewer, delete associated files
3324
3369
  if not viewer_segments:
3325
3370
  base, _ = os.path.splitext(image_path)
@@ -3334,7 +3379,7 @@ class MainWindow(QMainWindow):
3334
3379
  if hasattr(self, "right_panel") and hasattr(
3335
3380
  self.right_panel, "file_manager"
3336
3381
  ):
3337
- self.right_panel.file_manager.updateNpzStatus(
3382
+ self.right_panel.file_manager.updateFileStatus(
3338
3383
  Path(image_path)
3339
3384
  )
3340
3385
  except Exception as e:
@@ -3353,13 +3398,15 @@ class MainWindow(QMainWindow):
3353
3398
 
3354
3399
  # Update FastFileManager to show NPZ checkmarks for multi-view
3355
3400
  if hasattr(self, "multi_view_images") and self.multi_view_images:
3356
- for image_path in self.multi_view_images:
3357
- if (
3358
- image_path
3359
- and hasattr(self, "right_panel")
3360
- and hasattr(self.right_panel, "file_manager")
3361
- ):
3362
- self.right_panel.file_manager.updateNpzStatus(Path(image_path))
3401
+ if hasattr(self, "right_panel") and hasattr(
3402
+ self.right_panel, "file_manager"
3403
+ ):
3404
+ # Batch update for better performance
3405
+ valid_paths = [Path(img) for img in self.multi_view_images if img]
3406
+ if valid_paths:
3407
+ self.right_panel.file_manager.batchUpdateFileStatus(valid_paths)
3408
+ # Force immediate GUI update
3409
+ QApplication.processEvents()
3363
3410
  # Clear the tracking list for next save
3364
3411
  self._saved_file_paths = []
3365
3412
 
@@ -3395,7 +3442,7 @@ class MainWindow(QMainWindow):
3395
3442
  if hasattr(self, "right_panel") and hasattr(
3396
3443
  self.right_panel, "file_manager"
3397
3444
  ):
3398
- self.right_panel.file_manager.updateNpzStatus(
3445
+ self.right_panel.file_manager.updateFileStatus(
3399
3446
  Path(self.current_image_path)
3400
3447
  )
3401
3448
  except Exception as e:
@@ -3408,6 +3455,8 @@ class MainWindow(QMainWindow):
3408
3455
  )
3409
3456
  # Update UI immediately when files are deleted
3410
3457
  self._update_all_lists()
3458
+ # Force immediate GUI update
3459
+ QApplication.processEvents()
3411
3460
  else:
3412
3461
  self._show_warning_notification("No segments to save.")
3413
3462
  return
@@ -3445,6 +3494,8 @@ class MainWindow(QMainWindow):
3445
3494
  else:
3446
3495
  logger.warning("No classes defined for saving")
3447
3496
  self._show_warning_notification("No classes defined for saving.")
3497
+ # Save TXT file if enabled
3498
+ txt_path = None
3448
3499
  if settings.get("save_txt", True):
3449
3500
  h, w = (
3450
3501
  self.viewer._pixmap_item.pixmap().height(),
@@ -3458,23 +3509,41 @@ class MainWindow(QMainWindow):
3458
3509
  else:
3459
3510
  class_labels = [str(cid) for cid in class_order]
3460
3511
  if class_order:
3461
- self.file_manager.save_yolo_txt(
3512
+ txt_path = self.file_manager.save_yolo_txt(
3462
3513
  self.current_image_path,
3463
3514
  (h, w),
3464
3515
  class_order,
3465
3516
  class_labels,
3466
3517
  self.current_crop_coords,
3467
3518
  )
3468
- # Update FastFileManager to show NPZ checkmarks
3469
- if (
3470
- npz_path
3471
- and hasattr(self, "right_panel")
3472
- and hasattr(self.right_panel, "file_manager")
3519
+ if txt_path:
3520
+ logger.debug(f"TXT save completed: {txt_path}")
3521
+ self._show_success_notification(
3522
+ f"Saved: {os.path.basename(txt_path)}"
3523
+ )
3524
+
3525
+ # Save class aliases if enabled
3526
+ if settings.get("save_class_aliases", False):
3527
+ aliases_path = self.file_manager.save_class_aliases(
3528
+ self.current_image_path
3529
+ )
3530
+ if aliases_path:
3531
+ logger.debug(f"Class aliases saved: {aliases_path}")
3532
+ self._show_success_notification(
3533
+ f"Saved: {os.path.basename(aliases_path)}"
3534
+ )
3535
+
3536
+ # Update FastFileManager to show NPZ/TXT checkmarks
3537
+ # Always update file status after save attempt (regardless of what was saved)
3538
+ if hasattr(self, "right_panel") and hasattr(
3539
+ self.right_panel, "file_manager"
3473
3540
  ):
3474
- # Update the NPZ status in the FastFileManager
3475
- self.right_panel.file_manager.updateNpzStatus(
3541
+ # Update the file status in the FastFileManager
3542
+ self.right_panel.file_manager.updateFileStatus(
3476
3543
  Path(self.current_image_path)
3477
3544
  )
3545
+ # Force immediate GUI update
3546
+ QApplication.processEvents()
3478
3547
  except Exception as e:
3479
3548
  logger.error(f"Error saving file: {str(e)}", exc_info=True)
3480
3549
  self._show_error_notification(f"Error saving: {str(e)}")
@@ -5702,27 +5771,50 @@ class MainWindow(QMainWindow):
5702
5771
 
5703
5772
  def _update_channel_threshold_for_image(self, pixmap):
5704
5773
  """Update channel threshold widget for the given image pixmap."""
5705
- if pixmap.isNull():
5774
+ if pixmap.isNull() or not self.current_image_path:
5706
5775
  self.control_panel.update_channel_threshold_for_image(None)
5707
5776
  return
5708
5777
 
5709
- # Convert pixmap to numpy array
5710
- qimage = pixmap.toImage()
5711
- ptr = qimage.constBits()
5712
- ptr.setsize(qimage.bytesPerLine() * qimage.height())
5713
- image_np = np.array(ptr).reshape(qimage.height(), qimage.width(), 4)
5714
- # Convert from BGRA to RGB, ignore alpha
5715
- image_rgb = image_np[:, :, [2, 1, 0]]
5778
+ # Use cv2.imread for more robust loading instead of QPixmap conversion
5779
+ try:
5780
+ import cv2
5716
5781
 
5717
- # Check if image is grayscale (all channels are the same)
5718
- if np.array_equal(image_rgb[:, :, 0], image_rgb[:, :, 1]) and np.array_equal(
5719
- image_rgb[:, :, 1], image_rgb[:, :, 2]
5720
- ):
5721
- # Convert to single channel grayscale
5722
- image_array = image_rgb[:, :, 0]
5723
- else:
5724
- # Keep as RGB
5725
- image_array = image_rgb
5782
+ image_array = cv2.imread(self.current_image_path)
5783
+ if image_array is None:
5784
+ self.control_panel.update_channel_threshold_for_image(None)
5785
+ return
5786
+
5787
+ # Convert from BGR to RGB
5788
+ if len(image_array.shape) == 3 and image_array.shape[2] == 3:
5789
+ image_array = cv2.cvtColor(image_array, cv2.COLOR_BGR2RGB)
5790
+
5791
+ # Check if image is grayscale (all channels are the same)
5792
+ if (
5793
+ len(image_array.shape) == 3
5794
+ and np.array_equal(image_array[:, :, 0], image_array[:, :, 1])
5795
+ and np.array_equal(image_array[:, :, 1], image_array[:, :, 2])
5796
+ ):
5797
+ # Convert to single channel grayscale
5798
+ image_array = image_array[:, :, 0]
5799
+
5800
+ except Exception:
5801
+ # Fallback to QPixmap conversion if cv2 fails
5802
+ qimage = pixmap.toImage()
5803
+ ptr = qimage.constBits()
5804
+ ptr.setsize(qimage.bytesPerLine() * qimage.height())
5805
+ image_np = np.array(ptr).reshape(qimage.height(), qimage.width(), 4)
5806
+ # Convert from BGRA to RGB, ignore alpha
5807
+ image_rgb = image_np[:, :, [2, 1, 0]]
5808
+
5809
+ # Check if image is grayscale (all channels are the same)
5810
+ if np.array_equal(
5811
+ image_rgb[:, :, 0], image_rgb[:, :, 1]
5812
+ ) and np.array_equal(image_rgb[:, :, 1], image_rgb[:, :, 2]):
5813
+ # Convert to single channel grayscale
5814
+ image_array = image_rgb[:, :, 0]
5815
+ else:
5816
+ # Keep as RGB
5817
+ image_array = image_rgb
5726
5818
 
5727
5819
  # Update the channel threshold widget
5728
5820
  self.control_panel.update_channel_threshold_for_image(image_array)
@@ -5750,29 +5842,51 @@ class MainWindow(QMainWindow):
5750
5842
  self.control_panel.update_channel_threshold_for_image(None)
5751
5843
  return
5752
5844
 
5753
- # Load and convert the first image to update the widget
5754
- pixmap = QPixmap(first_image_path)
5755
- if pixmap.isNull():
5756
- self.control_panel.update_channel_threshold_for_image(None)
5757
- return
5845
+ # Use cv2.imread for more robust loading instead of QPixmap conversion
5846
+ try:
5847
+ import cv2
5758
5848
 
5759
- # Convert pixmap to numpy array
5760
- qimage = pixmap.toImage()
5761
- ptr = qimage.constBits()
5762
- ptr.setsize(qimage.bytesPerLine() * qimage.height())
5763
- image_np = np.array(ptr).reshape(qimage.height(), qimage.width(), 4)
5764
- # Convert from BGRA to RGB, ignore alpha
5765
- image_rgb = image_np[:, :, [2, 1, 0]]
5849
+ image_array = cv2.imread(first_image_path)
5850
+ if image_array is None:
5851
+ self.control_panel.update_channel_threshold_for_image(None)
5852
+ return
5766
5853
 
5767
- # Check if image is grayscale (all channels are the same)
5768
- if np.array_equal(image_rgb[:, :, 0], image_rgb[:, :, 1]) and np.array_equal(
5769
- image_rgb[:, :, 1], image_rgb[:, :, 2]
5770
- ):
5771
- # Convert to single channel grayscale
5772
- image_array = image_rgb[:, :, 0]
5773
- else:
5774
- # Keep as RGB
5775
- image_array = image_rgb
5854
+ # Convert from BGR to RGB
5855
+ if len(image_array.shape) == 3 and image_array.shape[2] == 3:
5856
+ image_array = cv2.cvtColor(image_array, cv2.COLOR_BGR2RGB)
5857
+
5858
+ # Check if image is grayscale (all channels are the same)
5859
+ if (
5860
+ len(image_array.shape) == 3
5861
+ and np.array_equal(image_array[:, :, 0], image_array[:, :, 1])
5862
+ and np.array_equal(image_array[:, :, 1], image_array[:, :, 2])
5863
+ ):
5864
+ # Convert to single channel grayscale
5865
+ image_array = image_array[:, :, 0]
5866
+
5867
+ except Exception:
5868
+ # Fallback to QPixmap conversion if cv2 fails
5869
+ pixmap = QPixmap(first_image_path)
5870
+ if pixmap.isNull():
5871
+ self.control_panel.update_channel_threshold_for_image(None)
5872
+ return
5873
+
5874
+ qimage = pixmap.toImage()
5875
+ ptr = qimage.constBits()
5876
+ ptr.setsize(qimage.bytesPerLine() * qimage.height())
5877
+ image_np = np.array(ptr).reshape(qimage.height(), qimage.width(), 4)
5878
+ # Convert from BGRA to RGB, ignore alpha
5879
+ image_rgb = image_np[:, :, [2, 1, 0]]
5880
+
5881
+ # Check if image is grayscale (all channels are the same)
5882
+ if np.array_equal(
5883
+ image_rgb[:, :, 0], image_rgb[:, :, 1]
5884
+ ) and np.array_equal(image_rgb[:, :, 1], image_rgb[:, :, 2]):
5885
+ # Convert to single channel grayscale
5886
+ image_array = image_rgb[:, :, 0]
5887
+ else:
5888
+ # Keep as RGB
5889
+ image_array = image_rgb
5776
5890
 
5777
5891
  # Update the channel threshold widget
5778
5892
  self.control_panel.update_channel_threshold_for_image(image_array)
@@ -7085,7 +7199,6 @@ class MainWindow(QMainWindow):
7085
7199
  )
7086
7200
 
7087
7201
  # Load existing segments for the current image
7088
- print(f"Loading segments for single-view: {self.current_image_path}")
7089
7202
  try:
7090
7203
  # Clear any leftover multi-view segments first
7091
7204
  self.segment_manager.clear()
@@ -7102,7 +7215,7 @@ class MainWindow(QMainWindow):
7102
7215
  self._update_all_lists()
7103
7216
 
7104
7217
  except Exception as e:
7105
- print(f"Error loading segments for single-view: {e}")
7218
+ logger.error(f"Error loading segments for single-view: {e}")
7106
7219
 
7107
7220
  # Redisplay segments for single view
7108
7221
  if hasattr(self, "single_view_mode_handler"):
@@ -7236,24 +7349,73 @@ class MainWindow(QMainWindow):
7236
7349
  self._load_multi_view_images(image_paths)
7237
7350
 
7238
7351
  def _load_next_multi_batch(self):
7239
- """Load the next batch of images using cached list."""
7240
- if not self.current_file_index.isValid():
7241
- return
7242
-
7243
- # Auto-save if enabled and we have current images (not the first load)
7244
- if self.multi_view_images[0] and self.control_panel.get_settings().get(
7245
- "auto_save", True
7352
+ """Load the next batch of images using fast file manager or cached list."""
7353
+ # Auto-save if enabled and we have current images
7354
+ if (
7355
+ hasattr(self, "multi_view_images")
7356
+ and self.multi_view_images
7357
+ and self.multi_view_images[0]
7358
+ and self.control_panel.get_settings().get("auto_save", True)
7246
7359
  ):
7247
7360
  self._save_multi_view_output()
7248
7361
 
7249
- # If cached list isn't ready, fall back to file model navigation
7362
+ # Get current image path - look for any valid image in multi-view state
7363
+ current_path = None
7364
+ if hasattr(self, "multi_view_images") and self.multi_view_images:
7365
+ # Find the first valid image path in the current multi-view state
7366
+ for img_path in self.multi_view_images:
7367
+ if img_path:
7368
+ current_path = img_path
7369
+ break
7370
+
7371
+ # Fallback to current_image_path if no valid multi-view images found
7372
+ if (
7373
+ not current_path
7374
+ and hasattr(self, "current_image_path")
7375
+ and self.current_image_path
7376
+ ):
7377
+ current_path = self.current_image_path
7378
+
7379
+ # If no valid path found, can't navigate forward
7380
+ if not current_path:
7381
+ return
7382
+
7383
+ # Get the number of viewers for multi-view mode
7384
+ config = self._get_multi_view_config()
7385
+ num_viewers = config["num_viewers"]
7386
+
7387
+ # Try to use fast file manager first (respects sorting/filtering)
7388
+ try:
7389
+ if (
7390
+ hasattr(self, "right_panel")
7391
+ and hasattr(self.right_panel, "file_manager")
7392
+ and hasattr(self.right_panel.file_manager, "getSurroundingFiles")
7393
+ ):
7394
+ file_manager = self.right_panel.file_manager
7395
+ surrounding_files = file_manager.getSurroundingFiles(
7396
+ Path(current_path), num_viewers * 2
7397
+ )
7398
+
7399
+ if len(surrounding_files) > num_viewers:
7400
+ # Skip ahead by num_viewers to get the next batch
7401
+ next_batch = surrounding_files[num_viewers : num_viewers * 2]
7402
+
7403
+ # Convert to strings and load
7404
+ images_to_load = [str(p) if p else None for p in next_batch]
7405
+ self._load_multi_view_images(images_to_load)
7406
+
7407
+ # Update file manager selection to first image of new batch
7408
+ if next_batch and next_batch[0]:
7409
+ self.right_panel.select_file(next_batch[0])
7410
+ return
7411
+ except Exception:
7412
+ pass # Fall back to cached list approach
7413
+
7414
+ # Fall back to cached list approach (for backward compatibility / tests)
7250
7415
  if not self.cached_image_paths:
7251
7416
  self._load_next_multi_batch_fallback()
7252
7417
  return
7253
7418
 
7254
- # Get current image path
7255
- current_path = self.file_model.filePath(self.current_file_index)
7256
-
7257
7419
  # Find current position in cached list
7258
7420
  try:
7259
7421
  current_index = self.cached_image_paths.index(current_path)
@@ -7262,13 +7424,14 @@ class MainWindow(QMainWindow):
7262
7424
  self._load_next_multi_batch_fallback()
7263
7425
  return
7264
7426
 
7265
- # Get the number of viewers for multi-view mode
7266
- config = self._get_multi_view_config()
7267
- num_viewers = config["num_viewers"]
7268
-
7269
7427
  # Skip num_viewers positions ahead in cached list
7270
7428
  target_index = current_index + num_viewers
7271
7429
 
7430
+ # Check if we can navigate forward (at least one valid image at target position)
7431
+ if target_index >= len(self.cached_image_paths):
7432
+ return # Can't navigate forward - at or past the end
7433
+
7434
+ # Check if we have at least one valid image at the target position
7272
7435
  if target_index < len(self.cached_image_paths):
7273
7436
  # Collect consecutive images
7274
7437
  image_paths = []
@@ -7278,39 +7441,88 @@ class MainWindow(QMainWindow):
7278
7441
  else:
7279
7442
  image_paths.append(None)
7280
7443
 
7281
- # Load all images
7282
- self._load_multi_view_images(image_paths)
7283
-
7284
- # Update current file index to the first image of the new batch
7285
- # Find the file model index for this path
7286
- if image_paths and image_paths[0]:
7287
- parent_index = self.current_file_index.parent()
7288
- for row in range(self.file_model.rowCount(parent_index)):
7289
- index = self.file_model.index(row, 0, parent_index)
7290
- if self.file_model.filePath(index) == image_paths[0]:
7291
- self.current_file_index = index
7292
- self.right_panel.file_tree.setCurrentIndex(index)
7293
- break
7444
+ # Only proceed if we have at least one valid image (prevent all-None batches)
7445
+ if any(path is not None for path in image_paths):
7446
+ # Load all images
7447
+ self._load_multi_view_images(image_paths)
7448
+
7449
+ # Update current file index to the first image of the new batch
7450
+ # Find the file model index for this path
7451
+ if image_paths and image_paths[0]:
7452
+ parent_index = self.current_file_index.parent()
7453
+ for row in range(self.file_model.rowCount(parent_index)):
7454
+ index = self.file_model.index(row, 0, parent_index)
7455
+ if self.file_model.filePath(index) == image_paths[0]:
7456
+ self.current_file_index = index
7457
+ self.right_panel.file_tree.setCurrentIndex(index)
7458
+ break
7459
+ # If all would be None, don't navigate (stay at current position)
7294
7460
 
7295
7461
  def _load_previous_multi_batch(self):
7296
- """Load the previous batch of images using cached list."""
7297
- if not self.current_file_index.isValid():
7298
- return
7299
-
7300
- # Auto-save if enabled and we have current images (not the first load)
7301
- if self.multi_view_images[0] and self.control_panel.get_settings().get(
7302
- "auto_save", True
7462
+ """Load the previous batch of images using fast file manager or cached list."""
7463
+ # Auto-save if enabled and we have current images
7464
+ if (
7465
+ hasattr(self, "multi_view_images")
7466
+ and self.multi_view_images
7467
+ and self.multi_view_images[0]
7468
+ and self.control_panel.get_settings().get("auto_save", True)
7303
7469
  ):
7304
7470
  self._save_multi_view_output()
7305
7471
 
7306
- # If cached list isn't ready, fall back to file model navigation
7472
+ # Get current image path - look for any valid image in multi-view state
7473
+ current_path = None
7474
+ if hasattr(self, "multi_view_images") and self.multi_view_images:
7475
+ # Find the first valid image path in the current multi-view state
7476
+ for img_path in self.multi_view_images:
7477
+ if img_path:
7478
+ current_path = img_path
7479
+ break
7480
+
7481
+ # Fallback to current_image_path if no valid multi-view images found
7482
+ if (
7483
+ not current_path
7484
+ and hasattr(self, "current_image_path")
7485
+ and self.current_image_path
7486
+ ):
7487
+ current_path = self.current_image_path
7488
+
7489
+ # If no valid path found, can't navigate backward
7490
+ if not current_path:
7491
+ return
7492
+
7493
+ # Get the number of viewers for multi-view mode
7494
+ config = self._get_multi_view_config()
7495
+ num_viewers = config["num_viewers"]
7496
+
7497
+ # Try to use fast file manager first (respects sorting/filtering)
7498
+ try:
7499
+ if (
7500
+ hasattr(self, "right_panel")
7501
+ and hasattr(self.right_panel, "file_manager")
7502
+ and hasattr(self.right_panel.file_manager, "getPreviousFiles")
7503
+ ):
7504
+ file_manager = self.right_panel.file_manager
7505
+ previous_batch = file_manager.getPreviousFiles(
7506
+ Path(current_path), num_viewers
7507
+ )
7508
+
7509
+ if previous_batch and any(previous_batch):
7510
+ # Convert to strings and load
7511
+ images_to_load = [str(p) if p else None for p in previous_batch]
7512
+ self._load_multi_view_images(images_to_load)
7513
+
7514
+ # Update file manager selection to first image of new batch
7515
+ if previous_batch and previous_batch[0]:
7516
+ self.right_panel.select_file(previous_batch[0])
7517
+ return
7518
+ except Exception:
7519
+ pass # Fall back to cached list approach
7520
+
7521
+ # Fall back to cached list approach (for backward compatibility / tests)
7307
7522
  if not self.cached_image_paths:
7308
7523
  self._load_previous_multi_batch_fallback()
7309
7524
  return
7310
7525
 
7311
- # Get current image path
7312
- current_path = self.file_model.filePath(self.current_file_index)
7313
-
7314
7526
  # Find current position in cached list
7315
7527
  try:
7316
7528
  current_index = self.cached_image_paths.index(current_path)
@@ -7319,10 +7531,6 @@ class MainWindow(QMainWindow):
7319
7531
  self._load_previous_multi_batch_fallback()
7320
7532
  return
7321
7533
 
7322
- # Get the number of viewers for multi-view mode
7323
- config = self._get_multi_view_config()
7324
- num_viewers = config["num_viewers"]
7325
-
7326
7534
  # Skip num_viewers positions back in cached list
7327
7535
  target_index = current_index - num_viewers
7328
7536
  if target_index < 0:
@@ -7331,24 +7539,30 @@ class MainWindow(QMainWindow):
7331
7539
  # Collect consecutive images
7332
7540
  image_paths = []
7333
7541
  for i in range(num_viewers):
7334
- if target_index + i < len(self.cached_image_paths):
7542
+ if (
7543
+ target_index + i < len(self.cached_image_paths)
7544
+ and target_index + i >= 0
7545
+ ):
7335
7546
  image_paths.append(self.cached_image_paths[target_index + i])
7336
7547
  else:
7337
7548
  image_paths.append(None)
7338
7549
 
7339
- # Load all images
7340
- self._load_multi_view_images(image_paths)
7550
+ # Only proceed if we have at least one valid image (prevent all-None batches)
7551
+ if any(path is not None for path in image_paths):
7552
+ # Load all images
7553
+ self._load_multi_view_images(image_paths)
7341
7554
 
7342
- # Update current file index to the first image of the new batch
7343
- # Find the file model index for this path
7344
- if image_paths and image_paths[0]:
7345
- parent_index = self.current_file_index.parent()
7346
- for row in range(self.file_model.rowCount(parent_index)):
7347
- index = self.file_model.index(row, 0, parent_index)
7348
- if self.file_model.filePath(index) == image_paths[0]:
7349
- self.current_file_index = index
7350
- self.right_panel.file_tree.setCurrentIndex(index)
7351
- break
7555
+ # Update current file index to the first image of the new batch
7556
+ # Find the file model index for this path
7557
+ if image_paths and image_paths[0]:
7558
+ parent_index = self.current_file_index.parent()
7559
+ for row in range(self.file_model.rowCount(parent_index)):
7560
+ index = self.file_model.index(row, 0, parent_index)
7561
+ if self.file_model.filePath(index) == image_paths[0]:
7562
+ self.current_file_index = index
7563
+ self.right_panel.file_tree.setCurrentIndex(index)
7564
+ break
7565
+ # If all would be None, don't navigate (stay at current position)
7352
7566
 
7353
7567
  def _load_next_multi_batch_fallback(self):
7354
7568
  """Fallback navigation using file model when cached list isn't available."""