coralnet-toolbox 0.0.73__py2.py3-none-any.whl → 0.0.75__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. coralnet_toolbox/Annotations/QtAnnotation.py +28 -69
  2. coralnet_toolbox/Annotations/QtMaskAnnotation.py +408 -0
  3. coralnet_toolbox/Annotations/QtMultiPolygonAnnotation.py +72 -56
  4. coralnet_toolbox/Annotations/QtPatchAnnotation.py +165 -216
  5. coralnet_toolbox/Annotations/QtPolygonAnnotation.py +497 -353
  6. coralnet_toolbox/Annotations/QtRectangleAnnotation.py +126 -116
  7. coralnet_toolbox/CoralNet/QtDownload.py +2 -1
  8. coralnet_toolbox/Explorer/QtDataItem.py +52 -22
  9. coralnet_toolbox/Explorer/QtExplorer.py +293 -1614
  10. coralnet_toolbox/Explorer/QtSettingsWidgets.py +203 -85
  11. coralnet_toolbox/Explorer/QtViewers.py +1568 -0
  12. coralnet_toolbox/Explorer/transformer_models.py +59 -0
  13. coralnet_toolbox/Explorer/yolo_models.py +112 -0
  14. coralnet_toolbox/IO/QtExportTagLabAnnotations.py +30 -10
  15. coralnet_toolbox/IO/QtImportTagLabAnnotations.py +21 -15
  16. coralnet_toolbox/IO/QtOpenProject.py +46 -78
  17. coralnet_toolbox/IO/QtSaveProject.py +18 -43
  18. coralnet_toolbox/MachineLearning/ExportDataset/QtBase.py +1 -1
  19. coralnet_toolbox/MachineLearning/ImportDataset/QtBase.py +253 -141
  20. coralnet_toolbox/MachineLearning/VideoInference/QtBase.py +0 -4
  21. coralnet_toolbox/MachineLearning/VideoInference/YOLO3D/run.py +102 -16
  22. coralnet_toolbox/QtAnnotationWindow.py +16 -10
  23. coralnet_toolbox/QtEventFilter.py +11 -0
  24. coralnet_toolbox/QtImageWindow.py +120 -75
  25. coralnet_toolbox/QtLabelWindow.py +13 -1
  26. coralnet_toolbox/QtMainWindow.py +5 -27
  27. coralnet_toolbox/QtProgressBar.py +52 -27
  28. coralnet_toolbox/Rasters/RasterTableModel.py +28 -8
  29. coralnet_toolbox/SAM/QtDeployGenerator.py +1 -4
  30. coralnet_toolbox/SAM/QtDeployPredictor.py +11 -3
  31. coralnet_toolbox/SeeAnything/QtDeployGenerator.py +805 -162
  32. coralnet_toolbox/SeeAnything/QtDeployPredictor.py +130 -151
  33. coralnet_toolbox/Tools/QtCutSubTool.py +18 -2
  34. coralnet_toolbox/Tools/QtPolygonTool.py +42 -3
  35. coralnet_toolbox/Tools/QtRectangleTool.py +30 -0
  36. coralnet_toolbox/Tools/QtResizeSubTool.py +19 -2
  37. coralnet_toolbox/Tools/QtSAMTool.py +72 -50
  38. coralnet_toolbox/Tools/QtSeeAnythingTool.py +8 -5
  39. coralnet_toolbox/Tools/QtSelectTool.py +27 -3
  40. coralnet_toolbox/Tools/QtSubtractSubTool.py +66 -0
  41. coralnet_toolbox/Tools/__init__.py +2 -0
  42. coralnet_toolbox/__init__.py +1 -1
  43. coralnet_toolbox/utilities.py +158 -47
  44. coralnet_toolbox-0.0.75.dist-info/METADATA +378 -0
  45. {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.75.dist-info}/RECORD +49 -44
  46. coralnet_toolbox-0.0.73.dist-info/METADATA +0 -341
  47. {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.75.dist-info}/WHEEL +0 -0
  48. {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.75.dist-info}/entry_points.txt +0 -0
  49. {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.75.dist-info}/licenses/LICENSE.txt +0 -0
  50. {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.75.dist-info}/top_level.txt +0 -0
@@ -189,6 +189,21 @@ Examples:
189
189
  help='Filter by specific class IDs (e.g., --classes 0 1 2 for persons, bicycles, cars)'
190
190
  )
191
191
 
192
+ # Frame range parameters
193
+ parser.add_argument(
194
+ '--start_at',
195
+ type=int,
196
+ default=0,
197
+ help='Start processing at this frame number (0-based)'
198
+ )
199
+ parser.add_argument(
200
+ '--end_at',
201
+ type=int,
202
+ default=None,
203
+ help='End processing at this frame number (inclusive, 0-based). If not provided, '
204
+ 'process until the end of the video.'
205
+ )
206
+
192
207
  # Device settings
193
208
  parser.add_argument(
194
209
  '--device',
@@ -222,6 +237,8 @@ Examples:
222
237
  action='store_true',
223
238
  help='Show all visualization windows (result, depth, detection). By default, only the result frame is shown.'
224
239
  )
240
+
241
+
225
242
  return parser.parse_args()
226
243
 
227
244
 
@@ -271,19 +288,25 @@ def main():
271
288
  enable_bev = not args.no_bev
272
289
  enable_display = not args.no_display
273
290
  show_all_frames = args.yes_display
291
+
292
+ # Frame range parameters
293
+ start_frame = args.start_at
294
+ end_frame = args.end_at
295
+
274
296
  # Camera parameters - simplified approach
275
297
  camera_params_file = None # Path to camera parameters file (None to use default parameters)
276
298
  # ===============================================
277
- print(f"\nConfiguration:")
299
+ print("\nConfiguration:")
278
300
  print(f"Input source: {source}")
279
301
  print(f"Output path: {output_path}")
280
302
  if target_size is not None:
281
303
  print(f"Target size: {target_size}px (longest edge)")
282
304
  else:
283
- print(f"Using original resolution (no scaling)")
305
+ print("Using original resolution (no scaling)")
284
306
  print(f"YOLO model: {'Custom path: ' + yolo_model_path if yolo_model_path else 'Size: ' + yolo_model_size}")
285
307
  print(f"Depth model size: {depth_model_size}")
286
308
  print(f"Device: {device}")
309
+ print(f"Frame range: {start_frame} to {end_frame if end_frame is not None else 'end'}")
287
310
  print(f"Tracking: {'enabled' if enable_tracking else 'disabled'}")
288
311
  print(f"Bird's Eye View: {'enabled' if enable_bev else 'disabled'}")
289
312
  print(f"Display: {'enabled' if enable_display else 'disabled'}")
@@ -382,9 +405,52 @@ def main():
382
405
  # Use a scale that works well for the 1-5 meter range
383
406
  bev = BirdEyeView(image_shape=(width, height), scale=100) # Increased scale to spread objects out
384
407
 
385
- # Initialize video writer
386
- fourcc = cv2.VideoWriter_fourcc(*'mp4v')
387
- out = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
408
+ # Initialize video writer - use a more reliable codec and check output path
409
+ output_dir = os.path.dirname(output_path)
410
+ if output_dir and not os.path.exists(output_dir):
411
+ os.makedirs(output_dir)
412
+
413
+ # On Windows, try different codec options
414
+ if sys.platform == 'win32':
415
+ try:
416
+ # First try H264 codec
417
+ fourcc = cv2.VideoWriter_fourcc(*'H264')
418
+ out = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
419
+
420
+ # Check if writer was successfully initialized
421
+ if not out.isOpened():
422
+ # Fallback to XVID codec
423
+ fourcc = cv2.VideoWriter_fourcc(*'XVID')
424
+ out = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
425
+
426
+ if not out.isOpened():
427
+ # Last resort, try MJPG
428
+ fourcc = cv2.VideoWriter_fourcc(*'MJPG')
429
+ out = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
430
+
431
+ if not out.isOpened():
432
+ print(f"Warning: Could not create output video with standard codecs. Trying AVI format.")
433
+ # Try changing extension to .avi
434
+ output_path = os.path.splitext(output_path)[0] + '.avi'
435
+ fourcc = cv2.VideoWriter_fourcc(*'XVID')
436
+ out = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
437
+ except Exception as e:
438
+ print(f"Error initializing video writer: {e}")
439
+ # Last fallback to MP4V
440
+ fourcc = cv2.VideoWriter_fourcc(*'mp4v')
441
+ out = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
442
+ else:
443
+ # For other platforms, use mp4v codec
444
+ fourcc = cv2.VideoWriter_fourcc(*'mp4v')
445
+ out = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
446
+
447
+ # Check if writer was successfully initialized
448
+ if not out.isOpened():
449
+ print(f"Error: Could not create output video file at {output_path}")
450
+ print("Continuing without saving output video.")
451
+ out = None
452
+ else:
453
+ print(f"Successfully opened output video file: {output_path}")
388
454
 
389
455
  # Initialize variables for FPS calculation
390
456
  frame_count = 0
@@ -394,6 +460,7 @@ def main():
394
460
  print("Starting processing...")
395
461
 
396
462
  # Main loop
463
+ current_frame = 0
397
464
  while True:
398
465
  # Check for key press at the beginning of each loop
399
466
  if enable_display:
@@ -401,11 +468,22 @@ def main():
401
468
  if key == ord('q') or key == 27 or (key & 0xFF) == ord('q') or (key & 0xFF) == 27:
402
469
  print("Exiting program...")
403
470
  break
404
- try: # Read frame
471
+ try:
472
+ # Read frame
405
473
  ret, frame = cap.read()
406
474
  if not ret:
407
475
  break
408
476
 
477
+ # Skip frames before start_frame
478
+ if current_frame < start_frame:
479
+ current_frame += 1
480
+ continue
481
+
482
+ # Stop if we've processed the end_frame
483
+ if end_frame is not None and current_frame > end_frame:
484
+ print(f"Reached end frame {end_frame}, stopping processing.")
485
+ break
486
+
409
487
  # Apply resizing if needed
410
488
  if target_size is not None:
411
489
  frame = cv2.resize(frame, (width, height))
@@ -543,7 +621,7 @@ def main():
543
621
  fps_display = f"FPS: {fps_value:.1f}"
544
622
 
545
623
  # Add FPS and device info to the result frame (top-right corner)
546
- text = f"{fps_display} | Device: {device}"
624
+ text = f"{fps_display} | Device: {device} | Frame: {current_frame}"
547
625
 
548
626
  # Calculate text size for right alignment
549
627
  (text_width, text_height), _ = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, 0.7, 2)
@@ -584,8 +662,9 @@ def main():
584
662
  except Exception as e:
585
663
  print(f"Error adding depth map to result: {e}")
586
664
 
587
- # Write frame to output video
588
- out.write(result_frame)
665
+ # Write frame to output video (only if writer is valid)
666
+ if out is not None and out.isOpened():
667
+ out.write(result_frame)
589
668
 
590
669
  # Display frames only if display is enabled
591
670
  if enable_display:
@@ -594,12 +673,15 @@ def main():
594
673
  cv2.imshow("Depth Map", depth_colored)
595
674
  cv2.imshow("Object Detection", detection_frame)
596
675
 
597
- # Check for key press again at the end of the loop
598
- key = cv2.waitKey(1)
599
- if key == ord('q') or key == 27 or (key & 0xFF) == ord('q') or (key & 0xFF) == 27:
600
- print("Exiting program...")
601
- break
602
-
676
+ # Check for key press again at the end of the loop
677
+ key = cv2.waitKey(1)
678
+ if key == ord('q') or key == 27 or (key & 0xFF) == ord('q') or (key & 0xFF) == 27:
679
+ print("Exiting program...")
680
+ break
681
+
682
+ # Increment frame counter
683
+ current_frame += 1
684
+
603
685
  except Exception as e:
604
686
  print(f"Error processing frame: {e}")
605
687
  # Also check for key press during exception handling
@@ -608,12 +690,16 @@ def main():
608
690
  if key == ord('q') or key == 27 or (key & 0xFF) == ord('q') or (key & 0xFF) == 27:
609
691
  print("Exiting program...")
610
692
  break
693
+
694
+ # Still increment frame counter even if there was an error
695
+ current_frame += 1
611
696
  continue
612
697
 
613
698
  # Clean up
614
699
  print("Cleaning up resources...")
615
700
  cap.release()
616
- out.release()
701
+ if out is not None:
702
+ out.release()
617
703
  cv2.destroyAllWindows()
618
704
 
619
705
  print(f"Processing complete. Output saved to {output_path}")
@@ -492,9 +492,6 @@ class AnnotationWindow(QGraphicsView):
492
492
  relative_area = 1.0 # fallback, treat as full image
493
493
 
494
494
  # Step 3: Map ratio to padding factor (smaller annotation = more padding)
495
- # Example: padding_factor = clamp(0.5 * (1/relative_area)**0.5, 0.1, 0.5)
496
- # - For very small annotations, padding approaches 0.5 (50%)
497
- # - For large annotations, padding approaches 0.1 (10%)
498
495
  import math
499
496
  min_padding = 0.1 # 10%
500
497
  max_padding = 0.5 # 50%
@@ -503,23 +500,32 @@ class AnnotationWindow(QGraphicsView):
503
500
  else:
504
501
  padding_factor = min_padding
505
502
 
506
- # Step 4: Apply dynamic padding
507
- padding_x = annotation_rect.width() * padding_factor
508
- padding_y = annotation_rect.height() * padding_factor
503
+ # Step 4: Apply dynamic padding with minimum values to prevent zero width/height
504
+ min_padding_absolute = 1.0 # Minimum padding in pixels
505
+ padding_x = max(annotation_rect.width() * padding_factor, min_padding_absolute)
506
+ padding_y = max(annotation_rect.height() * padding_factor, min_padding_absolute)
509
507
  padded_rect = annotation_rect.adjusted(-padding_x, -padding_y, padding_x, padding_y)
510
508
 
511
509
  # Fit the padded annotation rect in the view
512
510
  self.fitInView(padded_rect, Qt.KeepAspectRatio)
513
511
 
514
- # Update the zoom factor based on the new view transformation
512
+ # Update the zoom factor based on the new view transformation with safety checks
515
513
  view_rect = self.viewport().rect()
516
- zoom_x = view_rect.width() / padded_rect.width()
517
- zoom_y = view_rect.height() / padded_rect.height()
514
+ if padded_rect.width() > 0:
515
+ zoom_x = view_rect.width() / padded_rect.width()
516
+ else:
517
+ zoom_x = 1.0 # Default zoom if width is zero
518
+
519
+ if padded_rect.height() > 0:
520
+ zoom_y = view_rect.height() / padded_rect.height()
521
+ else:
522
+ zoom_y = 1.0 # Default zoom if height is zero
523
+
518
524
  self.zoom_factor = min(zoom_x, zoom_y)
519
525
 
520
526
  # Signal that the view has changed
521
527
  self.viewChanged.emit(*self.get_image_dimensions())
522
-
528
+
523
529
  def cycle_annotations(self, direction):
524
530
  """Cycle through annotations in the specified direction."""
525
531
  # Get the annotations for the current image
@@ -91,9 +91,20 @@ class GlobalEventFilter(QObject):
91
91
 
92
92
  # Delete (backspace or delete key) selected annotations when select tool is active
93
93
  if event.key() == Qt.Key_Delete or event.key() == Qt.Key_Backspace:
94
+ # First check if the select tool is active
94
95
  if self.main_window.select_tool_action.isChecked():
96
+ selected_tool = self.annotation_window.selected_tool
97
+ select_tool = self.annotation_window.tools[selected_tool]
98
+ # Get the active subtool if it exists, pass to its keyPressEvent
99
+ if hasattr(select_tool, 'active_subtool') and select_tool.active_subtool:
100
+ select_tool.active_subtool.keyPressEvent(event)
101
+ return True
102
+
103
+ # Otherwise, proceed with deletion if there are selected annotations
95
104
  if self.annotation_window.selected_annotations:
96
105
  self.annotation_window.delete_selected_annotations()
106
+ return True
107
+
97
108
  # Consume the event so it doesn't do anything else
98
109
  return True
99
110
 
@@ -1,22 +1,18 @@
1
1
  import warnings
2
2
 
3
3
  import os
4
- import gc
5
4
  from contextlib import contextmanager
6
5
 
7
6
  import rasterio
8
7
 
9
- from PyQt5.QtGui import QImage, QPixmap
10
- from PyQt5.QtCore import Qt, pyqtSignal, QTimer, QPoint, QThreadPool, QItemSelectionModel
8
+ from PyQt5.QtCore import Qt, pyqtSignal, QTimer, QPoint, QThreadPool, QItemSelectionModel, QModelIndex
11
9
  from PyQt5.QtWidgets import (QSizePolicy, QMessageBox, QCheckBox, QWidget, QVBoxLayout,
12
10
  QLabel, QComboBox, QHBoxLayout, QTableView, QHeaderView, QApplication,
13
- QMenu, QButtonGroup, QAbstractItemView, QGroupBox, QPushButton,
14
- QStyle, QFormLayout, QFrame)
15
-
16
- from coralnet_toolbox.Rasters import Raster, RasterManager, ImageFilter, RasterTableModel
11
+ QMenu, QButtonGroup, QGroupBox, QPushButton, QStyle,
12
+ QFormLayout, QFrame)
17
13
 
14
+ from coralnet_toolbox.Rasters import RasterManager, ImageFilter, RasterTableModel
18
15
  from coralnet_toolbox.QtProgressBar import ProgressBar
19
-
20
16
  from coralnet_toolbox.Icons import get_icon
21
17
 
22
18
 
@@ -30,11 +26,24 @@ warnings.filterwarnings("ignore", category=rasterio.errors.NotGeoreferencedWarni
30
26
 
31
27
 
32
28
  class NoArrowKeyTableView(QTableView):
29
+ # Custom signal to be emitted only on a left-click
30
+ leftClicked = pyqtSignal(QModelIndex)
31
+
33
32
  def keyPressEvent(self, event):
34
33
  if event.key() in (Qt.Key_Up, Qt.Key_Down):
35
34
  event.ignore()
36
35
  return
37
36
  super().keyPressEvent(event)
37
+
38
+ def mousePressEvent(self, event):
39
+ # On a left mouse press, emit our custom signal
40
+ if event.button() == Qt.LeftButton:
41
+ index = self.indexAt(event.pos())
42
+ if index.isValid():
43
+ self.leftClicked.emit(index)
44
+ # Call the base class implementation to handle standard behavior
45
+ # like row selection and context menu triggers.
46
+ super().mousePressEvent(event)
38
47
 
39
48
 
40
49
  class ImageWindow(QWidget):
@@ -256,10 +265,11 @@ class ImageWindow(QWidget):
256
265
  self.table_model = RasterTableModel(self.raster_manager, self)
257
266
  self.tableView.setModel(self.table_model)
258
267
 
259
- # Set column widths - removed checkbox column, adjust accordingly
260
- self.tableView.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
261
- self.tableView.setColumnWidth(1, 120)
262
- self.tableView.horizontalHeader().setSectionResizeMode(1, QHeaderView.Fixed)
268
+ # Set column widths
269
+ self.tableView.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) # Checkmark column
270
+ self.tableView.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) # Filename column
271
+ self.tableView.setColumnWidth(2, 120) # Annotation column
272
+ self.tableView.horizontalHeader().setSectionResizeMode(2, QHeaderView.Fixed)
263
273
 
264
274
  # Style the header
265
275
  self.tableView.horizontalHeader().setStyleSheet("""
@@ -271,7 +281,7 @@ class ImageWindow(QWidget):
271
281
  """)
272
282
 
273
283
  # Connect signals for clicking
274
- self.tableView.pressed.connect(self.on_table_pressed)
284
+ self.tableView.leftClicked.connect(self.on_table_pressed)
275
285
  self.tableView.doubleClicked.connect(self.on_table_double_clicked)
276
286
 
277
287
  # Add table view to the layout
@@ -413,59 +423,62 @@ class ImageWindow(QWidget):
413
423
  #
414
424
 
415
425
  def on_table_pressed(self, index):
416
- """Handle a single click on the table view."""
426
+ """Handle a single left-click on the table view with complex modifier support."""
417
427
  if not index.isValid():
418
428
  return
419
-
420
- # Get the path at the clicked row
429
+
421
430
  path = self.table_model.get_path_at_row(index.row())
422
431
  if not path:
423
432
  return
424
433
 
425
- # Get keyboard modifiers
426
434
  modifiers = QApplication.keyboardModifiers()
427
-
428
- # Handle highlighting logic
429
- if modifiers & Qt.ControlModifier:
430
- # Ctrl+Click: Toggle highlight for the clicked row
435
+ current_row = index.row()
436
+
437
+ # Define conditions for modifiers
438
+ has_ctrl = bool(modifiers & Qt.ControlModifier)
439
+ has_shift = bool(modifiers & Qt.ShiftModifier)
440
+
441
+ if has_shift:
442
+ # This block handles both Shift+Click and Ctrl+Shift+Click.
443
+ # First, determine the paths in the selection range.
444
+ range_paths = []
445
+ if self.last_highlighted_row >= 0:
446
+ start = min(self.last_highlighted_row, current_row)
447
+ end = max(self.last_highlighted_row, current_row)
448
+ for r in range(start, end + 1):
449
+ p = self.table_model.get_path_at_row(r)
450
+ if p:
451
+ range_paths.append(p)
452
+ else:
453
+ # If there's no anchor, the range is just the clicked item.
454
+ range_paths.append(path)
455
+
456
+ if not has_ctrl:
457
+ # Case 1: Simple Shift+Click. Clears previous highlights
458
+ # and selects only the new range.
459
+ self.table_model.set_highlighted_paths(range_paths)
460
+ else:
461
+ # Case 2: Ctrl+Shift+Click. Adds the new range to the
462
+ # existing highlighted rows without clearing them.
463
+ for p in range_paths:
464
+ self.table_model.highlight_path(p, True)
465
+
466
+ elif has_ctrl:
467
+ # Case 3: Ctrl+Click. Toggles a single row's highlight state
468
+ # and sets it as the new anchor for future shift-clicks.
431
469
  raster = self.raster_manager.get_raster(path)
432
470
  if raster:
433
471
  self.table_model.highlight_path(path, not raster.is_highlighted)
434
- # Update highlighted count
435
- self.update_highlighted_count_label()
436
-
437
- elif modifiers & Qt.ShiftModifier:
438
- # Shift+Click: Highlight range from last highlighted to current
439
- if self.last_highlighted_row >= 0:
440
- # Get the current row and last highlighted row
441
- current_row = index.row()
442
-
443
- # Calculate range (handle both directions)
444
- start_row = min(self.last_highlighted_row, current_row)
445
- end_row = max(self.last_highlighted_row, current_row)
446
-
447
- # Highlight the range
448
- for row in range(start_row, end_row + 1):
449
- path_to_highlight = self.table_model.get_path_at_row(row)
450
- if path_to_highlight:
451
- self.table_model.highlight_path(path_to_highlight, True)
452
- else:
453
- # No previous selection, just highlight the current row
454
- self.table_model.highlight_path(path, True)
455
-
456
- # Update the last highlighted row
457
- self.last_highlighted_row = index.row()
458
-
459
- # Update highlighted count
460
- self.update_highlighted_count_label()
472
+ self.last_highlighted_row = current_row
473
+
461
474
  else:
462
- # Regular click: Clear all highlights and highlight only this row
463
- self.table_model.clear_highlights()
464
- self.table_model.highlight_path(path, True)
465
- self.last_highlighted_row = index.row()
466
-
467
- # Update highlighted count
468
- self.update_highlighted_count_label()
475
+ # Case 4: Plain Click. Clears everything and highlights only
476
+ # the clicked row, setting it as the new anchor.
477
+ self.table_model.set_highlighted_paths([path])
478
+ self.last_highlighted_row = current_row
479
+
480
+ # Finally, update the count label after any changes.
481
+ self.update_highlighted_count_label()
469
482
 
470
483
  def on_table_double_clicked(self, index):
471
484
  """Handle double click on table view (selects image and loads it)."""
@@ -521,6 +534,24 @@ class ImageWindow(QWidget):
521
534
  """Handler for when an image is loaded."""
522
535
  self.selected_image_path = path
523
536
 
537
+ def on_toggle(self, new_state: bool):
538
+ """
539
+ Sets the checked state for all currently highlighted rows.
540
+
541
+ Args:
542
+ new_state (bool): The new state to set (True for checked, False for unchecked).
543
+ """
544
+ highlighted_paths = self.table_model.get_highlighted_paths()
545
+ if not highlighted_paths:
546
+ return
547
+
548
+ for path in highlighted_paths:
549
+ raster = self.raster_manager.get_raster(path)
550
+ if raster:
551
+ raster.checkbox_state = new_state
552
+ # Notify the model to update the view for this specific raster
553
+ self.table_model.update_raster_data(path)
554
+
524
555
  #
525
556
  # Public methods
526
557
  #
@@ -547,13 +578,9 @@ class ImageWindow(QWidget):
547
578
 
548
579
  # Immediately update filtered paths to include the new image
549
580
  # This ensures the image will be visible in the table right away
550
- if self.table_model.filtered_paths == self.raster_manager.image_paths[:-1]:
551
- # No filters are active, so just add the new path
552
- self.table_model.filtered_paths.append(image_path)
553
- self.table_model.dataChanged.emit(
554
- self.table_model.index(0, 0),
555
- self.table_model.index(len(self.table_model.filtered_paths) - 1,
556
- self.table_model.columnCount() - 1))
581
+ if len(self.table_model.filtered_paths) == len(self.raster_manager.image_paths) - 1:
582
+ # No filters are active, so efficiently add the new path to the model
583
+ self.table_model.add_path(image_path)
557
584
  else:
558
585
  # Filters are active, so run filtering again
559
586
  self.filter_images()
@@ -960,28 +987,46 @@ class ImageWindow(QWidget):
960
987
 
961
988
  def show_context_menu(self, position):
962
989
  """
963
- Show the context menu for the table.
990
+ Show the context menu for the table, including the toggle check state action.
964
991
 
965
992
  Args:
966
993
  position (QPoint): Position to show the menu
967
994
  """
995
+ # Get the path corresponding to the right-clicked row
996
+ index = self.tableView.indexAt(position)
997
+ path_at_cursor = self.table_model.get_path_at_row(index.row()) if index.isValid() else None
998
+
999
+ # Get the currently highlighted paths from the model
968
1000
  highlighted_paths = self.table_model.get_highlighted_paths()
969
- if not highlighted_paths:
970
- # If no highlights, highlight the row under the cursor only
971
- index = self.tableView.indexAt(position)
972
- if index.isValid():
973
- path = self.table_model.get_path_at_row(index.row())
974
- if path:
975
- self.table_model.set_highlighted_paths([path])
976
- self.last_highlighted_row = index.row()
977
- highlighted_paths = [path]
978
- else:
979
- # If any highlights, ensure all highlighted rows are used (no change needed)
980
- self.table_model.set_highlighted_paths(highlighted_paths)
1001
+
1002
+ # If the user right-clicked on a row that wasn't already highlighted,
1003
+ # then we assume they want to act on this row alone.
1004
+ if path_at_cursor and path_at_cursor not in highlighted_paths:
1005
+ self.table_model.set_highlighted_paths([path_at_cursor])
1006
+ self.last_highlighted_row = index.row()
1007
+ highlighted_paths = [path_at_cursor]
1008
+
1009
+ # If no rows are highlighted, do nothing.
981
1010
  if not highlighted_paths:
982
1011
  return
1012
+
983
1013
  context_menu = QMenu(self)
984
1014
  count = len(highlighted_paths)
1015
+
1016
+ # Add the check/uncheck action
1017
+ raster_under_cursor = self.raster_manager.get_raster(path_at_cursor)
1018
+ if raster_under_cursor:
1019
+ is_checked = raster_under_cursor.checkbox_state
1020
+ if is_checked:
1021
+ action_text = f"Uncheck {count} Highlighted Image{'s' if count > 1 else ''}"
1022
+ else:
1023
+ action_text = f"Check {count} Highlighted Image{'s' if count > 1 else ''}"
1024
+ toggle_check_action = context_menu.addAction(action_text)
1025
+ toggle_check_action.triggered.connect(lambda: self.on_toggle(not is_checked))
1026
+
1027
+ context_menu.addSeparator()
1028
+
1029
+ # Add existing delete actions
985
1030
  delete_images_action = context_menu.addAction(f"Delete {count} Highlighted Image{'s' if count > 1 else ''}")
986
1031
  delete_images_action.triggered.connect(lambda: self.delete_highlighted_images())
987
1032
  delete_annotations_action = context_menu.addAction(
@@ -485,7 +485,19 @@ class LabelWindow(QWidget):
485
485
  self.scroll_content.setFixedWidth(self.labels_per_row * self.label_width)
486
486
 
487
487
  def reorganize_labels(self):
488
- """Rearrange labels in the grid layout based on the current order and labels_per_row."""
488
+ """
489
+ Rearrange labels in the grid layout based on the current order and labels_per_row.
490
+ """
491
+ # First, clear the existing layout to remove any lingering widgets.
492
+ # This prevents references to deleted widgets from persisting in the layout.
493
+ while self.grid_layout.count():
494
+ item = self.grid_layout.takeAt(0)
495
+ widget = item.widget()
496
+ if widget:
497
+ # Setting the parent to None removes the widget from the layout's control.
498
+ widget.setParent(None)
499
+
500
+ # Now, add the current labels from the model back into the clean layout.
489
501
  for i, label in enumerate(self.labels):
490
502
  row = i // self.labels_per_row
491
503
  col = i % self.labels_per_row
@@ -2290,34 +2290,10 @@ class MainWindow(QMainWindow):
2290
2290
  "No images are present in the project.")
2291
2291
  return
2292
2292
 
2293
- if not self.annotation_window.annotations_dict:
2293
+ if len(self.label_window.labels) <= 1:
2294
2294
  QMessageBox.warning(self,
2295
2295
  "See Anything (YOLOE)",
2296
- "No annotations are present in the project.")
2297
- return
2298
-
2299
- valid_reference_types = {"PolygonAnnotation", "RectangleAnnotation"}
2300
- has_valid_reference = False
2301
-
2302
- # Iterate through the rasters in the main manager.
2303
- for raster in self.image_window.raster_manager.rasters.values():
2304
- # The values of our map are sets of annotation type names.
2305
- # e.g., [{'PointAnnotation'}, {'PolygonAnnotation', 'RectangleAnnotation'}]
2306
- for types_for_a_label in raster.label_to_types_map.values():
2307
- # Check if the set of types for this specific label
2308
- # has any overlap with our valid reference types.
2309
- if not valid_reference_types.isdisjoint(types_for_a_label):
2310
- # A valid reference type was found for at least one label on this raster.
2311
- has_valid_reference = True
2312
- break # Exit the inner loop (over types)
2313
-
2314
- if has_valid_reference:
2315
- break # Exit the outer loop (over rasters)
2316
-
2317
- if not has_valid_reference:
2318
- QMessageBox.warning(self,
2319
- "No Valid Reference Annotations",
2320
- "No images have polygon or rectangle annotations to use as a reference.")
2296
+ "At least one reference label is required for reference.")
2321
2297
  return
2322
2298
 
2323
2299
  try:
@@ -2518,7 +2494,9 @@ class MainWindow(QMainWindow):
2518
2494
  msg.setWindowIcon(self.coral_icon)
2519
2495
  msg.setWindowTitle("GDI Limit Reached")
2520
2496
  msg.setText(
2521
- "The GDI limit has been reached! Please immediately save your work, close, and reopen the application!"
2497
+ "The GDI limit is getting dangerously close to being reached (this is a known issue). "
2498
+ "Please immediately save your progress, close, and re-open the application. Failure to do so may "
2499
+ "result in data loss."
2522
2500
  )
2523
2501
  msg.setStandardButtons(QMessageBox.Ok)
2524
2502
  msg.exec_()