lazylabel-gui 1.0.9__py3-none-any.whl → 1.1.0__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 (39) hide show
  1. lazylabel/__init__.py +9 -0
  2. lazylabel/config/__init__.py +7 -0
  3. lazylabel/config/hotkeys.py +169 -0
  4. lazylabel/config/paths.py +41 -0
  5. lazylabel/config/settings.py +66 -0
  6. lazylabel/core/__init__.py +7 -0
  7. lazylabel/core/file_manager.py +106 -0
  8. lazylabel/core/model_manager.py +94 -0
  9. lazylabel/core/segment_manager.py +140 -0
  10. lazylabel/main.py +10 -1266
  11. lazylabel/models/__init__.py +5 -0
  12. lazylabel/models/sam_model.py +154 -0
  13. lazylabel/ui/__init__.py +8 -0
  14. lazylabel/ui/control_panel.py +220 -0
  15. lazylabel/{editable_vertex.py → ui/editable_vertex.py} +25 -3
  16. lazylabel/ui/hotkey_dialog.py +384 -0
  17. lazylabel/{hoverable_polygon_item.py → ui/hoverable_polygon_item.py} +17 -1
  18. lazylabel/ui/main_window.py +1264 -0
  19. lazylabel/ui/right_panel.py +239 -0
  20. lazylabel/ui/widgets/__init__.py +7 -0
  21. lazylabel/ui/widgets/adjustments_widget.py +107 -0
  22. lazylabel/ui/widgets/model_selection_widget.py +94 -0
  23. lazylabel/ui/widgets/settings_widget.py +106 -0
  24. lazylabel/utils/__init__.py +6 -0
  25. lazylabel/{custom_file_system_model.py → utils/custom_file_system_model.py} +9 -3
  26. {lazylabel_gui-1.0.9.dist-info → lazylabel_gui-1.1.0.dist-info}/METADATA +61 -11
  27. lazylabel_gui-1.1.0.dist-info/RECORD +36 -0
  28. lazylabel/controls.py +0 -265
  29. lazylabel/sam_model.py +0 -70
  30. lazylabel_gui-1.0.9.dist-info/RECORD +0 -17
  31. /lazylabel/{hoverable_pixelmap_item.py → ui/hoverable_pixelmap_item.py} +0 -0
  32. /lazylabel/{numeric_table_widget_item.py → ui/numeric_table_widget_item.py} +0 -0
  33. /lazylabel/{photo_viewer.py → ui/photo_viewer.py} +0 -0
  34. /lazylabel/{reorderable_class_table.py → ui/reorderable_class_table.py} +0 -0
  35. /lazylabel/{utils.py → utils/utils.py} +0 -0
  36. {lazylabel_gui-1.0.9.dist-info → lazylabel_gui-1.1.0.dist-info}/WHEEL +0 -0
  37. {lazylabel_gui-1.0.9.dist-info → lazylabel_gui-1.1.0.dist-info}/entry_points.txt +0 -0
  38. {lazylabel_gui-1.0.9.dist-info → lazylabel_gui-1.1.0.dist-info}/licenses/LICENSE +0 -0
  39. {lazylabel_gui-1.0.9.dist-info → lazylabel_gui-1.1.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1264 @@
1
+ """Main application window."""
2
+
3
+ import os
4
+ import numpy as np
5
+ import cv2
6
+ from PyQt6.QtWidgets import (
7
+ QMainWindow,
8
+ QWidget,
9
+ QHBoxLayout,
10
+ QFileDialog,
11
+ QApplication,
12
+ QGraphicsEllipseItem,
13
+ QGraphicsLineItem,
14
+ QGraphicsPolygonItem,
15
+ QTableWidgetItem,
16
+ QTableWidgetSelectionRange,
17
+ QHeaderView,
18
+ )
19
+ from PyQt6.QtGui import (
20
+ QIcon,
21
+ QKeySequence,
22
+ QShortcut,
23
+ QPixmap,
24
+ QColor,
25
+ QPen,
26
+ QBrush,
27
+ QPolygonF,
28
+ QImage,
29
+ )
30
+ from PyQt6.QtCore import Qt, QTimer, QModelIndex, QPointF
31
+
32
+ from .control_panel import ControlPanel
33
+ from .right_panel import RightPanel
34
+ from .photo_viewer import PhotoViewer
35
+ from .hoverable_polygon_item import HoverablePolygonItem
36
+ from .hoverable_pixelmap_item import HoverablePixmapItem
37
+ from .editable_vertex import EditableVertexItem
38
+ from .numeric_table_widget_item import NumericTableWidgetItem
39
+ from ..core import SegmentManager, ModelManager, FileManager
40
+ from ..config import Settings, Paths, HotkeyManager
41
+ from ..utils import CustomFileSystemModel, mask_to_pixmap
42
+ from .hotkey_dialog import HotkeyDialog
43
+
44
+
45
+ class MainWindow(QMainWindow):
46
+ """Main application window."""
47
+
48
+ def __init__(self):
49
+ super().__init__()
50
+
51
+ # Initialize configuration
52
+ self.paths = Paths()
53
+ self.settings = Settings.load_from_file(str(self.paths.settings_file))
54
+ self.hotkey_manager = HotkeyManager(str(self.paths.config_dir))
55
+
56
+ # Initialize managers
57
+ self.segment_manager = SegmentManager()
58
+ self.model_manager = ModelManager(self.paths)
59
+ self.file_manager = FileManager(self.segment_manager)
60
+
61
+ # Initialize UI state
62
+ self.mode = "sam_points"
63
+ self.previous_mode = "sam_points"
64
+ self.current_image_path = None
65
+ self.current_file_index = QModelIndex()
66
+
67
+ # Annotation state
68
+ self.point_radius = self.settings.point_radius
69
+ self.line_thickness = self.settings.line_thickness
70
+ self.pan_multiplier = self.settings.pan_multiplier
71
+ self.polygon_join_threshold = self.settings.polygon_join_threshold
72
+
73
+ # Drawing state
74
+ self.point_items, self.positive_points, self.negative_points = [], [], []
75
+ self.polygon_points, self.polygon_preview_items = [], []
76
+ self.rubber_band_line = None
77
+ self.preview_mask_item = None
78
+ self.segments, self.segment_items, self.highlight_items = [], {}, []
79
+ self.edit_handles = []
80
+ self.is_dragging_polygon, self.drag_start_pos, self.drag_initial_vertices = (
81
+ False,
82
+ None,
83
+ {},
84
+ )
85
+
86
+ self._setup_ui()
87
+ self._setup_model()
88
+ self._setup_connections()
89
+ self._setup_shortcuts()
90
+ self._load_settings()
91
+
92
+ def _setup_ui(self):
93
+ """Setup the user interface."""
94
+ self.setWindowTitle("LazyLabel by DNC")
95
+ self.setGeometry(
96
+ 50, 50, self.settings.window_width, self.settings.window_height
97
+ )
98
+
99
+ # Set window icon
100
+ if self.paths.logo_path.exists():
101
+ self.setWindowIcon(QIcon(str(self.paths.logo_path)))
102
+
103
+ # Create panels
104
+ self.control_panel = ControlPanel()
105
+ self.right_panel = RightPanel()
106
+ self.viewer = PhotoViewer(self)
107
+ self.viewer.setMouseTracking(True)
108
+
109
+ # Setup file model
110
+ self.file_model = CustomFileSystemModel()
111
+ self.right_panel.setup_file_model(self.file_model)
112
+
113
+ # Layout
114
+ main_layout = QHBoxLayout()
115
+ main_layout.addWidget(self.control_panel)
116
+ main_layout.addWidget(self.viewer, 1)
117
+ main_layout.addWidget(self.right_panel)
118
+
119
+ central_widget = QWidget()
120
+ central_widget.setLayout(main_layout)
121
+ self.setCentralWidget(central_widget)
122
+
123
+ def _setup_model(self):
124
+ """Setup the SAM model."""
125
+ sam_model = self.model_manager.initialize_default_model(
126
+ self.settings.default_model_type
127
+ )
128
+
129
+ if sam_model and sam_model.is_loaded:
130
+ self.control_panel.set_device_text(str(sam_model.device))
131
+ self._enable_sam_functionality(True)
132
+ else:
133
+ self.control_panel.set_device_text("No model loaded")
134
+ self._enable_sam_functionality(False)
135
+ print("SAM model failed to load. Point mode will be disabled.")
136
+
137
+ # Setup model change callback
138
+ self.model_manager.on_model_changed = self.control_panel.set_current_model
139
+
140
+ # Initialize models list
141
+ models = self.model_manager.get_available_models(str(self.paths.models_dir))
142
+ self.control_panel.populate_models(models)
143
+
144
+ def _enable_sam_functionality(self, enabled: bool):
145
+ """Enable or disable SAM point functionality."""
146
+ self.control_panel.set_sam_mode_enabled(enabled)
147
+ if not enabled and self.mode == "sam_points":
148
+ # Switch to polygon mode if SAM is disabled and we're in SAM mode
149
+ self.set_polygon_mode()
150
+
151
+ def _setup_connections(self):
152
+ """Setup signal connections."""
153
+ # Control panel connections
154
+ self.control_panel.sam_mode_requested.connect(self.set_sam_mode)
155
+ self.control_panel.polygon_mode_requested.connect(self.set_polygon_mode)
156
+ self.control_panel.selection_mode_requested.connect(self.toggle_selection_mode)
157
+ self.control_panel.clear_points_requested.connect(self.clear_all_points)
158
+ self.control_panel.fit_view_requested.connect(self.viewer.fitInView)
159
+ self.control_panel.hotkeys_requested.connect(self._show_hotkey_dialog)
160
+
161
+ # Model management
162
+ self.control_panel.browse_models_requested.connect(self._browse_models_folder)
163
+ self.control_panel.refresh_models_requested.connect(self._refresh_models_list)
164
+ self.control_panel.model_selected.connect(self._load_selected_model)
165
+
166
+ # Adjustments
167
+ self.control_panel.annotation_size_changed.connect(self._set_annotation_size)
168
+ self.control_panel.pan_speed_changed.connect(self._set_pan_speed)
169
+ self.control_panel.join_threshold_changed.connect(self._set_join_threshold)
170
+
171
+ # Right panel connections
172
+ self.right_panel.open_folder_requested.connect(self._open_folder_dialog)
173
+ self.right_panel.image_selected.connect(self._load_selected_image)
174
+ self.right_panel.merge_selection_requested.connect(
175
+ self._assign_selected_to_class
176
+ )
177
+ self.right_panel.delete_selection_requested.connect(
178
+ self._delete_selected_segments
179
+ )
180
+ self.right_panel.segments_selection_changed.connect(
181
+ self._highlight_selected_segments
182
+ )
183
+ self.right_panel.class_alias_changed.connect(self._handle_alias_change)
184
+ self.right_panel.reassign_classes_requested.connect(self._reassign_class_ids)
185
+ self.right_panel.class_filter_changed.connect(self._update_segment_table)
186
+
187
+ # Panel visibility
188
+ self.control_panel.btn_toggle_visibility.clicked.connect(
189
+ self.control_panel.toggle_visibility
190
+ )
191
+ self.right_panel.btn_toggle_visibility.clicked.connect(
192
+ self.right_panel.toggle_visibility
193
+ )
194
+
195
+ # Mouse events (will be implemented in a separate handler)
196
+ self._setup_mouse_events()
197
+
198
+ def _setup_shortcuts(self):
199
+ """Setup keyboard shortcuts based on hotkey manager."""
200
+ self.shortcuts = [] # Keep track of shortcuts for updating
201
+ self._update_shortcuts()
202
+
203
+ def _update_shortcuts(self):
204
+ """Update shortcuts based on current hotkey configuration."""
205
+ # Clear existing shortcuts
206
+ for shortcut in self.shortcuts:
207
+ shortcut.setParent(None)
208
+ self.shortcuts.clear()
209
+
210
+ # Map action names to callbacks
211
+ action_callbacks = {
212
+ "load_next_image": self._load_next_image,
213
+ "load_previous_image": self._load_previous_image,
214
+ "sam_mode": self.set_sam_mode,
215
+ "polygon_mode": self.set_polygon_mode,
216
+ "selection_mode": self.toggle_selection_mode,
217
+ "pan_mode": self.toggle_pan_mode,
218
+ "edit_mode": self.toggle_edit_mode,
219
+ "clear_points": self.clear_all_points,
220
+ "escape": self._handle_escape_press,
221
+ "delete_segments": self._delete_selected_segments,
222
+ "delete_segments_alt": self._delete_selected_segments,
223
+ "merge_segments": self._handle_merge_press,
224
+ "undo": self._undo_last_action,
225
+ "select_all": lambda: self.right_panel.select_all_segments(),
226
+ "save_segment": self._handle_space_press,
227
+ "save_output": self._handle_enter_press,
228
+ "save_output_alt": self._handle_enter_press,
229
+ "fit_view": self.viewer.fitInView,
230
+ "zoom_in": self._handle_zoom_in,
231
+ "zoom_out": self._handle_zoom_out,
232
+ "pan_up": lambda: self._handle_pan_key("up"),
233
+ "pan_down": lambda: self._handle_pan_key("down"),
234
+ "pan_left": lambda: self._handle_pan_key("left"),
235
+ "pan_right": lambda: self._handle_pan_key("right"),
236
+ }
237
+
238
+ # Create shortcuts for each action
239
+ for action_name, callback in action_callbacks.items():
240
+ primary_key, secondary_key = self.hotkey_manager.get_key_for_action(
241
+ action_name
242
+ )
243
+
244
+ # Create primary shortcut
245
+ if primary_key:
246
+ shortcut = QShortcut(QKeySequence(primary_key), self, callback)
247
+ self.shortcuts.append(shortcut)
248
+
249
+ # Create secondary shortcut
250
+ if secondary_key:
251
+ shortcut = QShortcut(QKeySequence(secondary_key), self, callback)
252
+ self.shortcuts.append(shortcut)
253
+
254
+ def _load_settings(self):
255
+ """Load and apply settings."""
256
+ self.control_panel.set_settings(self.settings.__dict__)
257
+ self.control_panel.set_annotation_size(
258
+ int(self.settings.annotation_size_multiplier * 10)
259
+ )
260
+ # Set initial mode based on model availability
261
+ if self.model_manager.is_model_available():
262
+ self.set_sam_mode()
263
+ else:
264
+ self.set_polygon_mode()
265
+
266
+ def _setup_mouse_events(self):
267
+ """Setup mouse event handling."""
268
+ self._original_mouse_press = self.viewer.scene().mousePressEvent
269
+ self._original_mouse_move = self.viewer.scene().mouseMoveEvent
270
+ self._original_mouse_release = self.viewer.scene().mouseReleaseEvent
271
+
272
+ self.viewer.scene().mousePressEvent = self._scene_mouse_press
273
+ self.viewer.scene().mouseMoveEvent = self._scene_mouse_move
274
+ self.viewer.scene().mouseReleaseEvent = self._scene_mouse_release
275
+
276
+ # Mode management methods
277
+ def set_sam_mode(self):
278
+ """Set SAM points mode."""
279
+ if not self.model_manager.is_model_available():
280
+ print("Cannot enter SAM mode: No model available")
281
+ return
282
+ self._set_mode("sam_points")
283
+
284
+ def set_polygon_mode(self):
285
+ """Set polygon drawing mode."""
286
+ self._set_mode("polygon")
287
+
288
+ def toggle_selection_mode(self):
289
+ """Toggle selection mode."""
290
+ self._toggle_mode("selection")
291
+
292
+ def toggle_pan_mode(self):
293
+ """Toggle pan mode."""
294
+ self._toggle_mode("pan")
295
+
296
+ def toggle_edit_mode(self):
297
+ """Toggle edit mode."""
298
+ self._toggle_mode("edit")
299
+
300
+ def _set_mode(self, mode_name, is_toggle=False):
301
+ """Set the current mode."""
302
+ if not is_toggle and self.mode not in ["selection", "edit"]:
303
+ self.previous_mode = self.mode
304
+
305
+ self.mode = mode_name
306
+ self.control_panel.set_mode_text(mode_name)
307
+ self.clear_all_points()
308
+
309
+ # Set cursor and drag mode based on mode
310
+ cursor_map = {
311
+ "sam_points": Qt.CursorShape.CrossCursor,
312
+ "polygon": Qt.CursorShape.CrossCursor,
313
+ "selection": Qt.CursorShape.ArrowCursor,
314
+ "edit": Qt.CursorShape.SizeAllCursor,
315
+ "pan": Qt.CursorShape.OpenHandCursor,
316
+ }
317
+ self.viewer.set_cursor(cursor_map.get(self.mode, Qt.CursorShape.ArrowCursor))
318
+
319
+ drag_mode = (
320
+ self.viewer.DragMode.ScrollHandDrag
321
+ if self.mode == "pan"
322
+ else self.viewer.DragMode.NoDrag
323
+ )
324
+ self.viewer.setDragMode(drag_mode)
325
+
326
+ # Update highlights and handles based on the new mode
327
+ self._highlight_selected_segments()
328
+ if mode_name == "edit":
329
+ self._display_edit_handles()
330
+ else:
331
+ self._clear_edit_handles()
332
+
333
+ def _toggle_mode(self, new_mode):
334
+ """Toggle between modes."""
335
+ if self.mode == new_mode:
336
+ self._set_mode(self.previous_mode, is_toggle=True)
337
+ else:
338
+ if self.mode not in ["selection", "edit"]:
339
+ self.previous_mode = self.mode
340
+ self._set_mode(new_mode, is_toggle=True)
341
+
342
+ # Model management methods
343
+ def _browse_models_folder(self):
344
+ """Browse for models folder."""
345
+ folder_path = QFileDialog.getExistingDirectory(self, "Select Models Folder")
346
+ if folder_path:
347
+ self.model_manager.set_models_folder(folder_path)
348
+ models = self.model_manager.get_available_models(folder_path)
349
+ self.control_panel.populate_models(models)
350
+ self.viewer.setFocus()
351
+
352
+ def _refresh_models_list(self):
353
+ """Refresh the models list."""
354
+ folder = self.model_manager.get_models_folder()
355
+ if folder and os.path.exists(folder):
356
+ models = self.model_manager.get_available_models(folder)
357
+ self.control_panel.populate_models(models)
358
+ self._show_notification("Models list refreshed.")
359
+ else:
360
+ self._show_notification("No models folder selected.")
361
+
362
+ def _load_selected_model(self, model_text):
363
+ """Load the selected model."""
364
+ if not model_text or model_text == "Default (vit_h)":
365
+ self.control_panel.set_current_model("Current: Default SAM Model")
366
+ return
367
+
368
+ model_path = self.control_panel.model_widget.get_selected_model_path()
369
+ if not model_path or not os.path.exists(model_path):
370
+ self._show_notification("Selected model file not found.")
371
+ return
372
+
373
+ self.control_panel.set_current_model("Loading model...")
374
+ QApplication.processEvents()
375
+
376
+ try:
377
+ success = self.model_manager.load_custom_model(model_path)
378
+ if success:
379
+ # Re-enable SAM functionality if model loaded successfully
380
+ self._enable_sam_functionality(True)
381
+ if self.model_manager.sam_model:
382
+ self.control_panel.set_device_text(
383
+ str(self.model_manager.sam_model.device)
384
+ )
385
+ else:
386
+ self.control_panel.set_current_model("Current: Default SAM Model")
387
+ self._show_notification("Failed to load selected model. Using default.")
388
+ self.control_panel.model_widget.reset_to_default()
389
+ self._enable_sam_functionality(False)
390
+ except Exception as e:
391
+ self.control_panel.set_current_model("Current: Default SAM Model")
392
+ self._show_notification(f"Error loading model: {str(e)}")
393
+ self.control_panel.model_widget.reset_to_default()
394
+ self._enable_sam_functionality(False)
395
+
396
+ # Adjustment methods
397
+ def _set_annotation_size(self, value):
398
+ """Set annotation size."""
399
+ multiplier = value / 10.0
400
+ self.point_radius = self.settings.point_radius * multiplier
401
+ self.line_thickness = self.settings.line_thickness * multiplier
402
+ self.settings.annotation_size_multiplier = multiplier
403
+ # Update display (implementation would go here)
404
+
405
+ def _set_pan_speed(self, value):
406
+ """Set pan speed."""
407
+ self.pan_multiplier = value / 10.0
408
+ self.settings.pan_multiplier = self.pan_multiplier
409
+
410
+ def _set_join_threshold(self, value):
411
+ """Set polygon join threshold."""
412
+ self.polygon_join_threshold = value
413
+ self.settings.polygon_join_threshold = value
414
+
415
+ # File management methods
416
+ def _open_folder_dialog(self):
417
+ """Open folder dialog for images."""
418
+ folder_path = QFileDialog.getExistingDirectory(self, "Select Image Folder")
419
+ if folder_path:
420
+ self.right_panel.set_folder(folder_path, self.file_model)
421
+ self.viewer.setFocus()
422
+
423
+ def _load_selected_image(self, index):
424
+ """Load the selected image."""
425
+ if not index.isValid() or not self.file_model.isDir(index.parent()):
426
+ return
427
+
428
+ self.current_file_index = index
429
+ path = self.file_model.filePath(index)
430
+
431
+ if os.path.isfile(path) and self.file_manager.is_image_file(path):
432
+ self.current_image_path = path
433
+ pixmap = QPixmap(self.current_image_path)
434
+ if not pixmap.isNull():
435
+ self._reset_state()
436
+ self.viewer.set_photo(pixmap)
437
+ if self.model_manager.is_model_available():
438
+ self.model_manager.sam_model.set_image(self.current_image_path)
439
+ self.file_manager.load_class_aliases(self.current_image_path)
440
+ self.file_manager.load_existing_mask(self.current_image_path)
441
+ self.right_panel.file_tree.setCurrentIndex(index)
442
+ self._update_all_lists()
443
+ self.viewer.setFocus()
444
+
445
+ def _load_next_image(self):
446
+ """Load next image in the file list, with auto-save if enabled."""
447
+ if not self.current_file_index.isValid():
448
+ return
449
+ # Auto-save if enabled
450
+ if self.control_panel.get_settings().get("auto_save", True):
451
+ self._save_output_to_npz()
452
+ parent = self.current_file_index.parent()
453
+ row = self.current_file_index.row()
454
+ # Find next valid image file
455
+ for next_row in range(row + 1, self.file_model.rowCount(parent)):
456
+ next_index = self.file_model.index(next_row, 0, parent)
457
+ path = self.file_model.filePath(next_index)
458
+ if os.path.isfile(path) and self.file_manager.is_image_file(path):
459
+ self._load_selected_image(next_index)
460
+ return
461
+
462
+ def _load_previous_image(self):
463
+ """Load previous image in the file list, with auto-save if enabled."""
464
+ if not self.current_file_index.isValid():
465
+ return
466
+ # Auto-save if enabled
467
+ if self.control_panel.get_settings().get("auto_save", True):
468
+ self._save_output_to_npz()
469
+ parent = self.current_file_index.parent()
470
+ row = self.current_file_index.row()
471
+ # Find previous valid image file
472
+ for prev_row in range(row - 1, -1, -1):
473
+ prev_index = self.file_model.index(prev_row, 0, parent)
474
+ path = self.file_model.filePath(prev_index)
475
+ if os.path.isfile(path) and self.file_manager.is_image_file(path):
476
+ self._load_selected_image(prev_index)
477
+ return
478
+
479
+ # Segment management methods
480
+ def _assign_selected_to_class(self):
481
+ """Assign selected segments to class."""
482
+ selected_indices = self.right_panel.get_selected_segment_indices()
483
+ self.segment_manager.assign_segments_to_class(selected_indices)
484
+ self._update_all_lists()
485
+
486
+ def _delete_selected_segments(self):
487
+ """Delete selected segments and remove any highlight overlays."""
488
+ # Remove highlight overlays before deleting segments
489
+ if hasattr(self, "highlight_items"):
490
+ for item in self.highlight_items:
491
+ self.viewer.scene().removeItem(item)
492
+ self.highlight_items = []
493
+ selected_indices = self.right_panel.get_selected_segment_indices()
494
+ self.segment_manager.delete_segments(selected_indices)
495
+ self._update_all_lists()
496
+
497
+ def _highlight_selected_segments(self):
498
+ """Highlight selected segments. In edit mode, use a brighter hover-like effect."""
499
+ # Remove previous highlight overlays
500
+ if hasattr(self, "highlight_items"):
501
+ for item in self.highlight_items:
502
+ if item.scene():
503
+ self.viewer.scene().removeItem(item)
504
+ self.highlight_items = []
505
+
506
+ selected_indices = self.right_panel.get_selected_segment_indices()
507
+ if not selected_indices:
508
+ return
509
+
510
+ for i in selected_indices:
511
+ seg = self.segment_manager.segments[i]
512
+ base_color = self._get_color_for_class(seg.get("class_id"))
513
+
514
+ if self.mode == "edit":
515
+ # Use a brighter, hover-like highlight in edit mode
516
+ highlight_brush = QBrush(
517
+ QColor(base_color.red(), base_color.green(), base_color.blue(), 170)
518
+ )
519
+ else:
520
+ # Use the standard yellow overlay for selection
521
+ highlight_brush = QBrush(QColor(255, 255, 0, 180))
522
+
523
+ if seg["type"] == "Polygon" and seg.get("vertices"):
524
+ poly_item = QGraphicsPolygonItem(QPolygonF(seg["vertices"]))
525
+ poly_item.setBrush(highlight_brush)
526
+ poly_item.setPen(QPen(Qt.GlobalColor.transparent))
527
+ poly_item.setZValue(99)
528
+ self.viewer.scene().addItem(poly_item)
529
+ self.highlight_items.append(poly_item)
530
+ elif seg.get("mask") is not None:
531
+ # For non-polygon types, we still use the mask-to-pixmap approach.
532
+ # If in edit mode, we could consider skipping non-polygons.
533
+ if self.mode != "edit":
534
+ mask = seg.get("mask")
535
+ pixmap = mask_to_pixmap(mask, (255, 255, 0), alpha=180)
536
+ highlight_item = self.viewer.scene().addPixmap(pixmap)
537
+ highlight_item.setZValue(100)
538
+ self.highlight_items.append(highlight_item)
539
+
540
+ def _handle_alias_change(self, class_id, alias):
541
+ """Handle class alias change."""
542
+ self.segment_manager.set_class_alias(class_id, alias)
543
+ self._update_all_lists()
544
+
545
+ def _reassign_class_ids(self):
546
+ """Reassign class IDs."""
547
+ new_order = self.right_panel.get_class_order()
548
+ self.segment_manager.reassign_class_ids(new_order)
549
+ self._update_all_lists()
550
+
551
+ def _update_segment_table(self):
552
+ """Update segment table."""
553
+ table = self.right_panel.segment_table
554
+ table.blockSignals(True)
555
+ selected_indices = self.right_panel.get_selected_segment_indices()
556
+ table.clearContents()
557
+ table.setRowCount(0)
558
+
559
+ # Get current filter
560
+ filter_text = self.right_panel.class_filter_combo.currentText()
561
+ show_all = filter_text == "All Classes"
562
+ filter_class_id = -1
563
+ if not show_all:
564
+ try:
565
+ # Parse format like "Alias: ID" or "Class ID"
566
+ if ":" in filter_text:
567
+ filter_class_id = int(filter_text.split(":")[-1].strip())
568
+ else:
569
+ filter_class_id = int(filter_text.split()[-1])
570
+ except (ValueError, IndexError):
571
+ show_all = True # If parsing fails, show all
572
+
573
+ # Filter segments based on class filter
574
+ display_segments = []
575
+ for i, seg in enumerate(self.segment_manager.segments):
576
+ seg_class_id = seg.get("class_id")
577
+ should_include = show_all or seg_class_id == filter_class_id
578
+ if should_include:
579
+ display_segments.append((i, seg))
580
+
581
+ table.setRowCount(len(display_segments))
582
+
583
+ # Populate table rows
584
+ for row, (original_index, seg) in enumerate(display_segments):
585
+ class_id = seg.get("class_id")
586
+ color = self._get_color_for_class(class_id)
587
+ class_id_str = str(class_id) if class_id is not None else "N/A"
588
+
589
+ alias_str = "N/A"
590
+ if class_id is not None:
591
+ alias_str = self.segment_manager.get_class_alias(class_id)
592
+
593
+ # Create table items (1-based segment ID for display)
594
+ index_item = NumericTableWidgetItem(str(original_index + 1))
595
+ class_item = NumericTableWidgetItem(class_id_str)
596
+ alias_item = QTableWidgetItem(alias_str)
597
+
598
+ # Set items as non-editable
599
+ index_item.setFlags(index_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
600
+ class_item.setFlags(class_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
601
+ alias_item.setFlags(alias_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
602
+
603
+ # Store original index for selection tracking
604
+ index_item.setData(Qt.ItemDataRole.UserRole, original_index)
605
+
606
+ # Set items in table
607
+ table.setItem(row, 0, index_item)
608
+ table.setItem(row, 1, class_item)
609
+ table.setItem(row, 2, alias_item)
610
+
611
+ # Set background color based on class
612
+ for col in range(table.columnCount()):
613
+ if table.item(row, col):
614
+ table.item(row, col).setBackground(QBrush(color))
615
+
616
+ # Restore selection
617
+ table.setSortingEnabled(False)
618
+ for row in range(table.rowCount()):
619
+ item = table.item(row, 0)
620
+ if item and item.data(Qt.ItemDataRole.UserRole) in selected_indices:
621
+ table.selectRow(row)
622
+ table.setSortingEnabled(True)
623
+
624
+ table.blockSignals(False)
625
+ self.viewer.setFocus()
626
+
627
+ def _update_all_lists(self):
628
+ """Update all UI lists."""
629
+ self._update_class_list()
630
+ self._update_segment_table()
631
+ self._update_class_filter()
632
+ self._display_all_segments()
633
+ if self.mode == "edit":
634
+ self._display_edit_handles()
635
+ else:
636
+ self._clear_edit_handles()
637
+
638
+ def _update_class_list(self):
639
+ """Update the class list in the right panel."""
640
+ class_table = self.right_panel.class_table
641
+ class_table.blockSignals(True)
642
+
643
+ # Get unique class IDs
644
+ unique_class_ids = self.segment_manager.get_unique_class_ids()
645
+
646
+ class_table.clearContents()
647
+ class_table.setRowCount(len(unique_class_ids))
648
+
649
+ for row, cid in enumerate(unique_class_ids):
650
+ alias_item = QTableWidgetItem(self.segment_manager.get_class_alias(cid))
651
+ id_item = QTableWidgetItem(str(cid))
652
+ id_item.setFlags(id_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
653
+
654
+ color = self._get_color_for_class(cid)
655
+ alias_item.setBackground(QBrush(color))
656
+ id_item.setBackground(QBrush(color))
657
+
658
+ class_table.setItem(row, 0, alias_item)
659
+ class_table.setItem(row, 1, id_item)
660
+
661
+ class_table.blockSignals(False)
662
+
663
+ def _update_class_filter(self):
664
+ """Update the class filter combo box."""
665
+ combo = self.right_panel.class_filter_combo
666
+ current_text = combo.currentText()
667
+
668
+ combo.blockSignals(True)
669
+ combo.clear()
670
+ combo.addItem("All Classes")
671
+
672
+ # Add class options
673
+ unique_class_ids = self.segment_manager.get_unique_class_ids()
674
+ for class_id in unique_class_ids:
675
+ alias = self.segment_manager.get_class_alias(class_id)
676
+ display_text = f"{alias}: {class_id}" if alias else f"Class {class_id}"
677
+ combo.addItem(display_text)
678
+
679
+ # Restore selection if possible
680
+ index = combo.findText(current_text)
681
+ if index >= 0:
682
+ combo.setCurrentIndex(index)
683
+ else:
684
+ combo.setCurrentIndex(0)
685
+
686
+ combo.blockSignals(False)
687
+
688
+ def _display_all_segments(self):
689
+ """Display all segments on the viewer."""
690
+ # Clear existing segment items
691
+ for i, items in self.segment_items.items():
692
+ for item in items:
693
+ if item.scene():
694
+ self.viewer.scene().removeItem(item)
695
+ self.segment_items.clear()
696
+ self._clear_edit_handles()
697
+
698
+ # Display segments from segment manager
699
+ for i, segment in enumerate(self.segment_manager.segments):
700
+ self.segment_items[i] = []
701
+ class_id = segment.get("class_id")
702
+ base_color = self._get_color_for_class(class_id)
703
+
704
+ if segment["type"] == "Polygon" and segment.get("vertices"):
705
+ poly_item = HoverablePolygonItem(QPolygonF(segment["vertices"]))
706
+ default_brush = QBrush(
707
+ QColor(base_color.red(), base_color.green(), base_color.blue(), 70)
708
+ )
709
+ hover_brush = QBrush(
710
+ QColor(base_color.red(), base_color.green(), base_color.blue(), 170)
711
+ )
712
+ poly_item.set_brushes(default_brush, hover_brush)
713
+ poly_item.setPen(QPen(Qt.GlobalColor.transparent))
714
+ self.viewer.scene().addItem(poly_item)
715
+ self.segment_items[i].append(poly_item)
716
+ elif segment.get("mask") is not None:
717
+ default_pixmap = mask_to_pixmap(
718
+ segment["mask"], base_color.getRgb()[:3], alpha=70
719
+ )
720
+ hover_pixmap = mask_to_pixmap(
721
+ segment["mask"], base_color.getRgb()[:3], alpha=170
722
+ )
723
+ pixmap_item = HoverablePixmapItem()
724
+ pixmap_item.set_pixmaps(default_pixmap, hover_pixmap)
725
+ self.viewer.scene().addItem(pixmap_item)
726
+ pixmap_item.setZValue(i + 1)
727
+ self.segment_items[i].append(pixmap_item)
728
+
729
+ # Event handlers
730
+ def _handle_escape_press(self):
731
+ """Handle escape key press."""
732
+ self.right_panel.clear_selections()
733
+ self.clear_all_points()
734
+ self.viewer.setFocus()
735
+
736
+ def _handle_space_press(self):
737
+ """Handle space key press."""
738
+ if self.mode == "polygon" and self.polygon_points:
739
+ self._finalize_polygon()
740
+ else:
741
+ self._save_current_segment()
742
+
743
+ def _handle_enter_press(self):
744
+ """Handle enter key press."""
745
+ if self.mode == "polygon" and self.polygon_points:
746
+ self._finalize_polygon()
747
+ else:
748
+ self._save_output_to_npz()
749
+
750
+ def _save_current_segment(self):
751
+ """Save current SAM segment."""
752
+ if (
753
+ self.mode != "sam_points"
754
+ or not hasattr(self, "preview_mask_item")
755
+ or not self.preview_mask_item
756
+ or not self.model_manager.is_model_available()
757
+ ):
758
+ return
759
+
760
+ mask = self.model_manager.sam_model.predict(
761
+ self.positive_points, self.negative_points
762
+ )
763
+ if mask is not None:
764
+ self.segment_manager.add_segment(
765
+ {
766
+ "mask": mask,
767
+ "type": "SAM",
768
+ "vertices": None,
769
+ }
770
+ )
771
+ self.clear_all_points()
772
+ self._update_all_lists()
773
+
774
+ def _finalize_polygon(self):
775
+ """Finalize polygon drawing."""
776
+ if len(self.polygon_points) < 3:
777
+ return
778
+
779
+ self.segment_manager.add_segment(
780
+ {
781
+ "vertices": list(self.polygon_points),
782
+ "type": "Polygon",
783
+ "mask": None,
784
+ }
785
+ )
786
+ self.polygon_points.clear()
787
+ self.clear_all_points()
788
+ self._update_all_lists()
789
+
790
+ def _save_output_to_npz(self):
791
+ """Save output to NPZ and TXT files as enabled, and update file list tickboxes/highlight. If no segments, delete associated files."""
792
+ if not self.current_image_path:
793
+ self._show_notification("No image loaded.")
794
+ return
795
+
796
+ # If no segments, delete associated files
797
+ if not self.segment_manager.segments:
798
+ base, _ = os.path.splitext(self.current_image_path)
799
+ deleted_files = []
800
+ for ext in [".npz", ".txt", ".json"]:
801
+ file_path = base + ext
802
+ if os.path.exists(file_path):
803
+ try:
804
+ os.remove(file_path)
805
+ deleted_files.append(file_path)
806
+ self.file_model.update_cache_for_path(file_path)
807
+ except Exception as e:
808
+ self._show_notification(f"Error deleting {file_path}: {e}")
809
+ if deleted_files:
810
+ self._show_notification(
811
+ f"Deleted: {', '.join(os.path.basename(f) for f in deleted_files)}"
812
+ )
813
+ else:
814
+ self._show_notification("No segments to save.")
815
+ return
816
+
817
+ try:
818
+ settings = self.control_panel.get_settings()
819
+ npz_path = None
820
+ txt_path = None
821
+ if settings.get("save_npz", True):
822
+ h, w = (
823
+ self.viewer._pixmap_item.pixmap().height(),
824
+ self.viewer._pixmap_item.pixmap().width(),
825
+ )
826
+ class_order = self.segment_manager.get_unique_class_ids()
827
+ if class_order:
828
+ npz_path = self.file_manager.save_npz(
829
+ self.current_image_path, (h, w), class_order
830
+ )
831
+ self._show_notification(f"Saved: {os.path.basename(npz_path)}")
832
+ else:
833
+ self._show_notification("No classes defined for saving.")
834
+ if settings.get("save_txt", True):
835
+ h, w = (
836
+ self.viewer._pixmap_item.pixmap().height(),
837
+ self.viewer._pixmap_item.pixmap().width(),
838
+ )
839
+ class_order = self.segment_manager.get_unique_class_ids()
840
+ if settings.get("yolo_use_alias", True):
841
+ class_labels = [
842
+ self.segment_manager.get_class_alias(cid) for cid in class_order
843
+ ]
844
+ else:
845
+ class_labels = [str(cid) for cid in class_order]
846
+ if class_order:
847
+ txt_path = self.file_manager.save_yolo_txt(
848
+ self.current_image_path, (h, w), class_order, class_labels
849
+ )
850
+ # Efficiently update file list tickboxes and highlight
851
+ for path in [npz_path, txt_path]:
852
+ if path:
853
+ self.file_model.update_cache_for_path(path)
854
+ self.file_model.set_highlighted_path(path)
855
+ QTimer.singleShot(
856
+ 1500,
857
+ lambda p=path: (
858
+ self.file_model.set_highlighted_path(None)
859
+ if self.file_model.highlighted_path == p
860
+ else None
861
+ ),
862
+ )
863
+ except Exception as e:
864
+ self._show_notification(f"Error saving: {str(e)}")
865
+
866
+ def _handle_merge_press(self):
867
+ """Handle merge key press."""
868
+ self._assign_selected_to_class()
869
+ self.right_panel.clear_selections()
870
+
871
+ def _undo_last_action(self):
872
+ """Undo last action."""
873
+ # Implementation would go here
874
+ pass
875
+
876
+ def clear_all_points(self):
877
+ """Clear all temporary points."""
878
+ if hasattr(self, "rubber_band_line") and self.rubber_band_line:
879
+ self.viewer.scene().removeItem(self.rubber_band_line)
880
+ self.rubber_band_line = None
881
+
882
+ self.positive_points.clear()
883
+ self.negative_points.clear()
884
+
885
+ for item in self.point_items:
886
+ self.viewer.scene().removeItem(item)
887
+ self.point_items.clear()
888
+
889
+ self.polygon_points.clear()
890
+ for item in self.polygon_preview_items:
891
+ self.viewer.scene().removeItem(item)
892
+ self.polygon_preview_items.clear()
893
+
894
+ if hasattr(self, "preview_mask_item") and self.preview_mask_item:
895
+ self.viewer.scene().removeItem(self.preview_mask_item)
896
+ self.preview_mask_item = None
897
+
898
+ def _show_notification(self, message, duration=3000):
899
+ """Show notification message."""
900
+ self.control_panel.show_notification(message)
901
+ QTimer.singleShot(duration, self.control_panel.clear_notification)
902
+
903
+ def _show_hotkey_dialog(self):
904
+ """Show the hotkey configuration dialog."""
905
+ dialog = HotkeyDialog(self.hotkey_manager, self)
906
+ dialog.exec()
907
+ # Update shortcuts after dialog closes
908
+ self._update_shortcuts()
909
+
910
+ def _handle_zoom_in(self):
911
+ """Handle zoom in."""
912
+ current_val = self.control_panel.get_annotation_size()
913
+ self.control_panel.set_annotation_size(min(current_val + 1, 50))
914
+
915
+ def _handle_zoom_out(self):
916
+ """Handle zoom out."""
917
+ current_val = self.control_panel.get_annotation_size()
918
+ self.control_panel.set_annotation_size(max(current_val - 1, 1))
919
+
920
+ def _handle_pan_key(self, direction):
921
+ """Handle WASD pan keys."""
922
+ if not hasattr(self, "viewer"):
923
+ return
924
+
925
+ amount = int(self.viewer.height() * 0.1 * self.pan_multiplier)
926
+
927
+ if direction == "up":
928
+ self.viewer.verticalScrollBar().setValue(
929
+ self.viewer.verticalScrollBar().value() - amount
930
+ )
931
+ elif direction == "down":
932
+ self.viewer.verticalScrollBar().setValue(
933
+ self.viewer.verticalScrollBar().value() + amount
934
+ )
935
+ elif direction == "left":
936
+ amount = int(self.viewer.width() * 0.1 * self.pan_multiplier)
937
+ self.viewer.horizontalScrollBar().setValue(
938
+ self.viewer.horizontalScrollBar().value() - amount
939
+ )
940
+ elif direction == "right":
941
+ amount = int(self.viewer.width() * 0.1 * self.pan_multiplier)
942
+ self.viewer.horizontalScrollBar().setValue(
943
+ self.viewer.horizontalScrollBar().value() + amount
944
+ )
945
+
946
+ def closeEvent(self, event):
947
+ """Handle application close."""
948
+ # Save settings
949
+ self.settings.save_to_file(str(self.paths.settings_file))
950
+ super().closeEvent(event)
951
+
952
+ def _reset_state(self):
953
+ """Reset application state."""
954
+ self.clear_all_points()
955
+ self.segment_manager.clear()
956
+ self._update_all_lists()
957
+ items_to_remove = [
958
+ item
959
+ for item in self.viewer.scene().items()
960
+ if item is not self.viewer._pixmap_item
961
+ ]
962
+ for item in items_to_remove:
963
+ self.viewer.scene().removeItem(item)
964
+ self.segment_items.clear()
965
+ self.highlight_items.clear()
966
+
967
+ def _scene_mouse_press(self, event):
968
+ """Handle mouse press events in the scene."""
969
+ # Map scene coordinates to the view so items() works correctly.
970
+ view_pos = self.viewer.mapFromScene(event.scenePos())
971
+ items_at_pos = self.viewer.items(view_pos)
972
+ is_handle_click = any(
973
+ isinstance(item, EditableVertexItem) for item in items_at_pos
974
+ )
975
+
976
+ # Allow vertex handles to process their own mouse events.
977
+ if is_handle_click:
978
+ self._original_mouse_press(event)
979
+ return
980
+
981
+ if self.mode == "edit" and event.button() == Qt.MouseButton.LeftButton:
982
+ pos = event.scenePos()
983
+ if self.viewer._pixmap_item.pixmap().rect().contains(pos.toPoint()):
984
+ self.is_dragging_polygon = True
985
+ self.drag_start_pos = pos
986
+ selected_indices = self.right_panel.get_selected_segment_indices()
987
+ self.drag_initial_vertices = {
988
+ i: list(self.segment_manager.segments[i]["vertices"])
989
+ for i in selected_indices
990
+ if self.segment_manager.segments[i].get("type") == "Polygon"
991
+ }
992
+ event.accept()
993
+ return
994
+
995
+ # Call the original scene handler.
996
+ self._original_mouse_press(event)
997
+ # Skip further processing unless we're in selection mode.
998
+ if event.isAccepted() and self.mode != "selection":
999
+ return
1000
+
1001
+ if self.is_dragging_polygon:
1002
+ return
1003
+
1004
+ pos = event.scenePos()
1005
+ if (
1006
+ self.viewer._pixmap_item.pixmap().isNull()
1007
+ or not self.viewer._pixmap_item.pixmap().rect().contains(pos.toPoint())
1008
+ ):
1009
+ return
1010
+
1011
+ if self.mode == "pan":
1012
+ self.viewer.set_cursor(Qt.CursorShape.ClosedHandCursor)
1013
+ elif self.mode == "sam_points":
1014
+ if event.button() == Qt.MouseButton.LeftButton:
1015
+ self._add_point(pos, positive=True)
1016
+ self._update_segmentation()
1017
+ elif event.button() == Qt.MouseButton.RightButton:
1018
+ self._add_point(pos, positive=False)
1019
+ self._update_segmentation()
1020
+ elif self.mode == "polygon":
1021
+ if event.button() == Qt.MouseButton.LeftButton:
1022
+ self._handle_polygon_click(pos)
1023
+ elif self.mode == "selection":
1024
+ if event.button() == Qt.MouseButton.LeftButton:
1025
+ self._handle_segment_selection_click(pos)
1026
+
1027
+ def _scene_mouse_move(self, event):
1028
+ """Handle mouse move events in the scene."""
1029
+ if self.mode == "edit" and self.is_dragging_polygon:
1030
+ delta = event.scenePos() - self.drag_start_pos
1031
+ for i, initial_verts in self.drag_initial_vertices.items():
1032
+ self.segment_manager.segments[i]["vertices"] = [
1033
+ QPointF(v) + delta for v in initial_verts
1034
+ ]
1035
+ self._update_polygon_item(i)
1036
+ self._display_edit_handles() # Redraw handles at new positions
1037
+ self._highlight_selected_segments() # Redraw highlight at new position
1038
+ event.accept()
1039
+ return
1040
+
1041
+ self._original_mouse_move(event)
1042
+
1043
+ def _scene_mouse_release(self, event):
1044
+ """Handle mouse release events in the scene."""
1045
+ if self.mode == "edit" and self.is_dragging_polygon:
1046
+ self.is_dragging_polygon = False
1047
+ self.drag_initial_vertices.clear()
1048
+ event.accept()
1049
+ return
1050
+
1051
+ if self.mode == "pan":
1052
+ self.viewer.set_cursor(Qt.CursorShape.OpenHandCursor)
1053
+ self._original_mouse_release(event)
1054
+
1055
+ def _add_point(self, pos, positive):
1056
+ """Add a point for SAM segmentation."""
1057
+ point_list = self.positive_points if positive else self.negative_points
1058
+ point_list.append([int(pos.x()), int(pos.y())])
1059
+
1060
+ point_color = (
1061
+ QColor(Qt.GlobalColor.green) if positive else QColor(Qt.GlobalColor.red)
1062
+ )
1063
+ point_color.setAlpha(150)
1064
+ point_diameter = self.point_radius * 2
1065
+
1066
+ point_item = QGraphicsEllipseItem(
1067
+ pos.x() - self.point_radius,
1068
+ pos.y() - self.point_radius,
1069
+ point_diameter,
1070
+ point_diameter,
1071
+ )
1072
+ point_item.setBrush(QBrush(point_color))
1073
+ point_item.setPen(QPen(Qt.GlobalColor.transparent))
1074
+ self.viewer.scene().addItem(point_item)
1075
+ self.point_items.append(point_item)
1076
+
1077
+ def _update_segmentation(self):
1078
+ """Update SAM segmentation preview."""
1079
+ if hasattr(self, "preview_mask_item") and self.preview_mask_item:
1080
+ self.viewer.scene().removeItem(self.preview_mask_item)
1081
+ if not self.positive_points or not self.model_manager.is_model_available():
1082
+ return
1083
+
1084
+ mask = self.model_manager.sam_model.predict(
1085
+ self.positive_points, self.negative_points
1086
+ )
1087
+ if mask is not None:
1088
+ pixmap = mask_to_pixmap(mask, (255, 255, 0))
1089
+ self.preview_mask_item = self.viewer.scene().addPixmap(pixmap)
1090
+ self.preview_mask_item.setZValue(50)
1091
+
1092
+ def _handle_polygon_click(self, pos):
1093
+ """Handle polygon drawing clicks."""
1094
+ # Check if clicking near the first point to close polygon
1095
+ if self.polygon_points and len(self.polygon_points) > 2:
1096
+ first_point = self.polygon_points[0]
1097
+ distance_squared = (pos.x() - first_point.x()) ** 2 + (
1098
+ pos.y() - first_point.y()
1099
+ ) ** 2
1100
+ if distance_squared < self.polygon_join_threshold**2:
1101
+ self._finalize_polygon()
1102
+ return
1103
+
1104
+ # Add new point to polygon
1105
+ self.polygon_points.append(pos)
1106
+
1107
+ # Create visual point
1108
+ point_diameter = self.point_radius * 2
1109
+ point_color = QColor(Qt.GlobalColor.blue)
1110
+ point_color.setAlpha(150)
1111
+ dot = QGraphicsEllipseItem(
1112
+ pos.x() - self.point_radius,
1113
+ pos.y() - self.point_radius,
1114
+ point_diameter,
1115
+ point_diameter,
1116
+ )
1117
+ dot.setBrush(QBrush(point_color))
1118
+ dot.setPen(QPen(Qt.GlobalColor.transparent))
1119
+ self.viewer.scene().addItem(dot)
1120
+ self.polygon_preview_items.append(dot)
1121
+
1122
+ # Update polygon preview
1123
+ self._draw_polygon_preview()
1124
+
1125
+ def _draw_polygon_preview(self):
1126
+ """Draw polygon preview lines and fill."""
1127
+ # Remove old preview lines and polygons (keep dots)
1128
+ for item in self.polygon_preview_items[:]:
1129
+ if not isinstance(item, QGraphicsEllipseItem):
1130
+ if item.scene():
1131
+ self.viewer.scene().removeItem(item)
1132
+ self.polygon_preview_items.remove(item)
1133
+
1134
+ if len(self.polygon_points) > 2:
1135
+ # Create preview polygon fill
1136
+ preview_poly = QGraphicsPolygonItem(QPolygonF(self.polygon_points))
1137
+ preview_poly.setBrush(QBrush(QColor(0, 255, 255, 100)))
1138
+ preview_poly.setPen(QPen(Qt.GlobalColor.transparent))
1139
+ self.viewer.scene().addItem(preview_poly)
1140
+ self.polygon_preview_items.append(preview_poly)
1141
+
1142
+ if len(self.polygon_points) > 1:
1143
+ # Create preview lines between points
1144
+ line_color = QColor(Qt.GlobalColor.cyan)
1145
+ line_color.setAlpha(150)
1146
+ for i in range(len(self.polygon_points) - 1):
1147
+ line = QGraphicsLineItem(
1148
+ self.polygon_points[i].x(),
1149
+ self.polygon_points[i].y(),
1150
+ self.polygon_points[i + 1].x(),
1151
+ self.polygon_points[i + 1].y(),
1152
+ )
1153
+ line.setPen(QPen(line_color, self.line_thickness))
1154
+ self.viewer.scene().addItem(line)
1155
+ self.polygon_preview_items.append(line)
1156
+
1157
+ def _handle_segment_selection_click(self, pos):
1158
+ """Handle segment selection clicks (toggle behavior)."""
1159
+ x, y = int(pos.x()), int(pos.y())
1160
+ for i in range(len(self.segment_manager.segments) - 1, -1, -1):
1161
+ seg = self.segment_manager.segments[i]
1162
+ # Determine mask for hit-testing
1163
+ if seg["type"] == "Polygon" and seg.get("vertices"):
1164
+ # Rasterize polygon
1165
+ if self.viewer._pixmap_item.pixmap().isNull():
1166
+ continue
1167
+ h = self.viewer._pixmap_item.pixmap().height()
1168
+ w = self.viewer._pixmap_item.pixmap().width()
1169
+ points_np = np.array(
1170
+ [[p.x(), p.y()] for p in seg["vertices"]], dtype=np.int32
1171
+ )
1172
+ # Ensure points are within bounds
1173
+ points_np = np.clip(points_np, 0, [w - 1, h - 1])
1174
+ mask = np.zeros((h, w), dtype=np.uint8)
1175
+ cv2.fillPoly(mask, [points_np], 1)
1176
+ mask = mask.astype(bool)
1177
+ else:
1178
+ mask = seg.get("mask")
1179
+ if (
1180
+ mask is not None
1181
+ and y < mask.shape[0]
1182
+ and x < mask.shape[1]
1183
+ and mask[y, x]
1184
+ ):
1185
+ # Find the corresponding row in the segment table and toggle selection
1186
+ table = self.right_panel.segment_table
1187
+ for j in range(table.rowCount()):
1188
+ item = table.item(j, 0)
1189
+ if item and item.data(Qt.ItemDataRole.UserRole) == i:
1190
+ # Toggle selection for this row using the original working method
1191
+ is_selected = table.item(j, 0).isSelected()
1192
+ range_to_select = QTableWidgetSelectionRange(
1193
+ j, 0, j, table.columnCount() - 1
1194
+ )
1195
+ table.setRangeSelected(range_to_select, not is_selected)
1196
+ self._highlight_selected_segments()
1197
+ return
1198
+ self.viewer.setFocus()
1199
+
1200
+ def _get_color_for_class(self, class_id):
1201
+ """Get color for a class ID."""
1202
+ if class_id is None:
1203
+ return QColor.fromHsv(0, 0, 128)
1204
+ hue = int((class_id * 222.4922359) % 360)
1205
+ color = QColor.fromHsv(hue, 220, 220)
1206
+ if not color.isValid():
1207
+ return QColor(Qt.GlobalColor.white)
1208
+ return color
1209
+
1210
+ def _display_edit_handles(self):
1211
+ """Display draggable vertex handles for selected polygons in edit mode."""
1212
+ self._clear_edit_handles()
1213
+ if self.mode != "edit":
1214
+ return
1215
+ selected_indices = self.right_panel.get_selected_segment_indices()
1216
+ handle_radius = self.point_radius
1217
+ handle_diam = handle_radius * 2
1218
+ for seg_idx in selected_indices:
1219
+ seg = self.segment_manager.segments[seg_idx]
1220
+ if seg["type"] == "Polygon" and seg.get("vertices"):
1221
+ for v_idx, pt in enumerate(seg["vertices"]):
1222
+ handle = EditableVertexItem(
1223
+ self,
1224
+ seg_idx,
1225
+ v_idx,
1226
+ -handle_radius,
1227
+ -handle_radius,
1228
+ handle_diam,
1229
+ handle_diam,
1230
+ )
1231
+ handle.setPos(pt) # Use setPos to handle zoom correctly
1232
+ handle.setZValue(200) # Ensure handles are on top
1233
+ # Make sure the handle can receive mouse events
1234
+ handle.setAcceptHoverEvents(True)
1235
+ self.viewer.scene().addItem(handle)
1236
+ self.edit_handles.append(handle)
1237
+
1238
+ def _clear_edit_handles(self):
1239
+ """Remove all editable vertex handles from the scene."""
1240
+ if hasattr(self, "edit_handles"):
1241
+ for h in self.edit_handles:
1242
+ if h.scene():
1243
+ self.viewer.scene().removeItem(h)
1244
+ self.edit_handles = []
1245
+
1246
+ def update_vertex_pos(self, segment_index, vertex_index, new_pos):
1247
+ """Update the position of a vertex in a polygon segment."""
1248
+ seg = self.segment_manager.segments[segment_index]
1249
+ if seg.get("type") == "Polygon":
1250
+ seg["vertices"][
1251
+ vertex_index
1252
+ ] = new_pos # new_pos is already the correct scene coordinate
1253
+ self._update_polygon_item(segment_index)
1254
+ self._highlight_selected_segments() # Keep the highlight in sync with the new shape
1255
+
1256
+ def _update_polygon_item(self, segment_index):
1257
+ """Efficiently update the visual polygon item for a given segment."""
1258
+ items = self.segment_items.get(segment_index, [])
1259
+ for item in items:
1260
+ if isinstance(item, HoverablePolygonItem):
1261
+ item.setPolygon(
1262
+ QPolygonF(self.segment_manager.segments[segment_index]["vertices"])
1263
+ )
1264
+ return