lazylabel-gui 1.1.2__py3-none-any.whl → 1.1.3__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.
- lazylabel/__init__.py +9 -9
- lazylabel/config/__init__.py +7 -7
- lazylabel/config/hotkeys.py +207 -169
- lazylabel/config/paths.py +40 -41
- lazylabel/config/settings.py +65 -66
- lazylabel/core/__init__.py +7 -7
- lazylabel/core/file_manager.py +122 -106
- lazylabel/core/model_manager.py +95 -97
- lazylabel/core/segment_manager.py +170 -171
- lazylabel/main.py +37 -36
- lazylabel/models/__init__.py +5 -5
- lazylabel/models/sam_model.py +200 -195
- lazylabel/ui/__init__.py +8 -8
- lazylabel/ui/control_panel.py +239 -241
- lazylabel/ui/editable_vertex.py +64 -64
- lazylabel/ui/hotkey_dialog.py +416 -384
- lazylabel/ui/hoverable_pixelmap_item.py +22 -22
- lazylabel/ui/hoverable_polygon_item.py +38 -39
- lazylabel/ui/main_window.py +1787 -1659
- lazylabel/ui/numeric_table_widget_item.py +9 -9
- lazylabel/ui/photo_viewer.py +51 -54
- lazylabel/ui/reorderable_class_table.py +60 -61
- lazylabel/ui/right_panel.py +314 -315
- lazylabel/ui/widgets/__init__.py +8 -8
- lazylabel/ui/widgets/adjustments_widget.py +108 -108
- lazylabel/ui/widgets/model_selection_widget.py +101 -94
- lazylabel/ui/widgets/settings_widget.py +113 -106
- lazylabel/ui/widgets/status_bar.py +109 -109
- lazylabel/utils/__init__.py +6 -6
- lazylabel/utils/custom_file_system_model.py +133 -132
- lazylabel/utils/utils.py +12 -12
- {lazylabel_gui-1.1.2.dist-info → lazylabel_gui-1.1.3.dist-info}/METADATA +243 -197
- lazylabel_gui-1.1.3.dist-info/RECORD +37 -0
- {lazylabel_gui-1.1.2.dist-info → lazylabel_gui-1.1.3.dist-info}/licenses/LICENSE +21 -21
- lazylabel_gui-1.1.2.dist-info/RECORD +0 -37
- {lazylabel_gui-1.1.2.dist-info → lazylabel_gui-1.1.3.dist-info}/WHEEL +0 -0
- {lazylabel_gui-1.1.2.dist-info → lazylabel_gui-1.1.3.dist-info}/entry_points.txt +0 -0
- {lazylabel_gui-1.1.2.dist-info → lazylabel_gui-1.1.3.dist-info}/top_level.txt +0 -0
lazylabel/ui/main_window.py
CHANGED
@@ -1,1659 +1,1787 @@
|
|
1
|
-
"""Main application window."""
|
2
|
-
|
3
|
-
import os
|
4
|
-
|
5
|
-
import cv2
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
QDialog,
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
from
|
34
|
-
|
35
|
-
from
|
36
|
-
from .
|
37
|
-
from .
|
38
|
-
from .
|
39
|
-
from .hoverable_pixelmap_item import HoverablePixmapItem
|
40
|
-
from .
|
41
|
-
from .numeric_table_widget_item import NumericTableWidgetItem
|
42
|
-
from
|
43
|
-
from
|
44
|
-
from
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
self.
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
layout
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
self.
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
self.
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
self.
|
103
|
-
self.
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
self.
|
113
|
-
self.
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
self.
|
119
|
-
self.
|
120
|
-
self.
|
121
|
-
self.
|
122
|
-
self.
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
self.
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
self.
|
139
|
-
self.
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
"
|
146
|
-
self.
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
self.
|
157
|
-
self.
|
158
|
-
self.viewer
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
self.file_model
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
self.status_bar
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
self.main_splitter
|
171
|
-
self.main_splitter.addWidget(self.
|
172
|
-
self.main_splitter.addWidget(self.
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
self.
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
self.main_splitter.
|
181
|
-
self.main_splitter.setStretchFactor(
|
182
|
-
self.main_splitter.setStretchFactor(
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
main_layout
|
193
|
-
main_layout.
|
194
|
-
main_layout.
|
195
|
-
|
196
|
-
|
197
|
-
central_widget
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
"
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
self.
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
)
|
218
|
-
self.
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
self.
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
self.
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
self.control_panel.
|
248
|
-
self.control_panel.
|
249
|
-
self.control_panel.
|
250
|
-
self.control_panel.
|
251
|
-
self.control_panel.
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
self.control_panel.
|
256
|
-
self.control_panel.
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
self.control_panel.
|
261
|
-
self.control_panel.
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
self.right_panel.
|
266
|
-
self.right_panel.
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
)
|
276
|
-
self.right_panel.
|
277
|
-
self.right_panel.
|
278
|
-
self.right_panel.
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
self.
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
self.
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
"
|
303
|
-
"
|
304
|
-
"
|
305
|
-
"
|
306
|
-
"
|
307
|
-
"
|
308
|
-
"
|
309
|
-
"
|
310
|
-
"
|
311
|
-
"
|
312
|
-
"
|
313
|
-
"
|
314
|
-
"
|
315
|
-
"select_all": lambda: self.right_panel.select_all_segments(),
|
316
|
-
"save_segment": self._handle_space_press,
|
317
|
-
"save_output": self._handle_enter_press,
|
318
|
-
"save_output_alt": self._handle_enter_press,
|
319
|
-
"fit_view": self.viewer.fitInView,
|
320
|
-
"zoom_in": self._handle_zoom_in,
|
321
|
-
"zoom_out": self._handle_zoom_out,
|
322
|
-
"pan_up": lambda: self._handle_pan_key("up"),
|
323
|
-
"pan_down": lambda: self._handle_pan_key("down"),
|
324
|
-
"pan_left": lambda: self._handle_pan_key("left"),
|
325
|
-
"pan_right": lambda: self._handle_pan_key("right"),
|
326
|
-
}
|
327
|
-
|
328
|
-
# Create shortcuts for each action
|
329
|
-
for action_name, callback in action_callbacks.items():
|
330
|
-
primary_key, secondary_key = self.hotkey_manager.get_key_for_action(
|
331
|
-
action_name
|
332
|
-
)
|
333
|
-
|
334
|
-
# Create primary shortcut
|
335
|
-
if primary_key:
|
336
|
-
shortcut = QShortcut(QKeySequence(primary_key), self, callback)
|
337
|
-
self.shortcuts.append(shortcut)
|
338
|
-
|
339
|
-
# Create secondary shortcut
|
340
|
-
if secondary_key:
|
341
|
-
shortcut = QShortcut(QKeySequence(secondary_key), self, callback)
|
342
|
-
self.shortcuts.append(shortcut)
|
343
|
-
|
344
|
-
def _load_settings(self):
|
345
|
-
"""Load and apply settings."""
|
346
|
-
self.control_panel.set_settings(self.settings.__dict__)
|
347
|
-
self.control_panel.set_annotation_size(
|
348
|
-
int(self.settings.annotation_size_multiplier * 10)
|
349
|
-
)
|
350
|
-
self.control_panel.set_join_threshold(self.settings.polygon_join_threshold)
|
351
|
-
# Set initial mode based on model availability
|
352
|
-
if self.model_manager.is_model_available():
|
353
|
-
self.set_sam_mode()
|
354
|
-
else:
|
355
|
-
self.set_polygon_mode()
|
356
|
-
|
357
|
-
def _setup_mouse_events(self):
|
358
|
-
"""Setup mouse event handling."""
|
359
|
-
self._original_mouse_press = self.viewer.scene().mousePressEvent
|
360
|
-
self._original_mouse_move = self.viewer.scene().mouseMoveEvent
|
361
|
-
self._original_mouse_release = self.viewer.scene().mouseReleaseEvent
|
362
|
-
|
363
|
-
self.viewer.scene().mousePressEvent = self._scene_mouse_press
|
364
|
-
self.viewer.scene().mouseMoveEvent = self._scene_mouse_move
|
365
|
-
self.viewer.scene().mouseReleaseEvent = self._scene_mouse_release
|
366
|
-
|
367
|
-
# Mode management methods
|
368
|
-
def set_sam_mode(self):
|
369
|
-
"""Set SAM points mode."""
|
370
|
-
if not self.model_manager.is_model_available():
|
371
|
-
print("Cannot enter SAM mode: No model available")
|
372
|
-
return
|
373
|
-
self._set_mode("sam_points")
|
374
|
-
|
375
|
-
def set_polygon_mode(self):
|
376
|
-
"""Set polygon drawing mode."""
|
377
|
-
self._set_mode("polygon")
|
378
|
-
|
379
|
-
def toggle_selection_mode(self):
|
380
|
-
"""Toggle selection mode."""
|
381
|
-
self._toggle_mode("selection")
|
382
|
-
|
383
|
-
def toggle_pan_mode(self):
|
384
|
-
"""Toggle pan mode."""
|
385
|
-
self._toggle_mode("pan")
|
386
|
-
|
387
|
-
def toggle_edit_mode(self):
|
388
|
-
"""Toggle edit mode."""
|
389
|
-
self._toggle_mode("edit")
|
390
|
-
|
391
|
-
def _set_mode(self, mode_name, is_toggle=False):
|
392
|
-
"""Set the current mode."""
|
393
|
-
if not is_toggle and self.mode not in ["selection", "edit"]:
|
394
|
-
self.previous_mode = self.mode
|
395
|
-
|
396
|
-
self.mode = mode_name
|
397
|
-
self.control_panel.set_mode_text(mode_name)
|
398
|
-
self.clear_all_points()
|
399
|
-
|
400
|
-
# Set cursor and drag mode based on mode
|
401
|
-
cursor_map = {
|
402
|
-
"sam_points": Qt.CursorShape.CrossCursor,
|
403
|
-
"polygon": Qt.CursorShape.CrossCursor,
|
404
|
-
"selection": Qt.CursorShape.ArrowCursor,
|
405
|
-
"edit": Qt.CursorShape.SizeAllCursor,
|
406
|
-
"pan": Qt.CursorShape.OpenHandCursor,
|
407
|
-
}
|
408
|
-
self.viewer.set_cursor(cursor_map.get(self.mode, Qt.CursorShape.ArrowCursor))
|
409
|
-
|
410
|
-
drag_mode = (
|
411
|
-
self.viewer.DragMode.ScrollHandDrag
|
412
|
-
if self.mode == "pan"
|
413
|
-
else self.viewer.DragMode.NoDrag
|
414
|
-
)
|
415
|
-
self.viewer.setDragMode(drag_mode)
|
416
|
-
|
417
|
-
# Update highlights and handles based on the new mode
|
418
|
-
self._highlight_selected_segments()
|
419
|
-
if mode_name == "edit":
|
420
|
-
self._display_edit_handles()
|
421
|
-
else:
|
422
|
-
self._clear_edit_handles()
|
423
|
-
|
424
|
-
def _toggle_mode(self, new_mode):
|
425
|
-
"""Toggle between modes."""
|
426
|
-
if self.mode == new_mode:
|
427
|
-
self._set_mode(self.previous_mode, is_toggle=True)
|
428
|
-
else:
|
429
|
-
if self.mode not in ["selection", "edit"]:
|
430
|
-
self.previous_mode = self.mode
|
431
|
-
self._set_mode(new_mode, is_toggle=True)
|
432
|
-
|
433
|
-
# Model management methods
|
434
|
-
def _browse_models_folder(self):
|
435
|
-
"""Browse for models folder."""
|
436
|
-
folder_path = QFileDialog.getExistingDirectory(self, "Select Models Folder")
|
437
|
-
if folder_path:
|
438
|
-
self.model_manager.set_models_folder(folder_path)
|
439
|
-
models = self.model_manager.get_available_models(folder_path)
|
440
|
-
self.control_panel.populate_models(models)
|
441
|
-
self.viewer.setFocus()
|
442
|
-
|
443
|
-
def _refresh_models_list(self):
|
444
|
-
"""Refresh the models list."""
|
445
|
-
folder = self.model_manager.get_models_folder()
|
446
|
-
if folder and os.path.exists(folder):
|
447
|
-
models = self.model_manager.get_available_models(folder)
|
448
|
-
self.control_panel.populate_models(models)
|
449
|
-
self._show_success_notification("Models list refreshed.")
|
450
|
-
else:
|
451
|
-
self._show_warning_notification("No models folder selected.")
|
452
|
-
|
453
|
-
def _load_selected_model(self, model_text):
|
454
|
-
"""Load the selected model."""
|
455
|
-
if not model_text or model_text == "Default (vit_h)":
|
456
|
-
self.control_panel.set_current_model("Current: Default SAM Model")
|
457
|
-
return
|
458
|
-
|
459
|
-
model_path = self.control_panel.model_widget.get_selected_model_path()
|
460
|
-
if not model_path or not os.path.exists(model_path):
|
461
|
-
self._show_error_notification("Selected model file not found.")
|
462
|
-
return
|
463
|
-
|
464
|
-
self.control_panel.set_current_model("Loading model...")
|
465
|
-
QApplication.processEvents()
|
466
|
-
|
467
|
-
try:
|
468
|
-
success = self.model_manager.load_custom_model(model_path)
|
469
|
-
if success:
|
470
|
-
# Re-enable SAM functionality if model loaded successfully
|
471
|
-
self._enable_sam_functionality(True)
|
472
|
-
if self.model_manager.sam_model:
|
473
|
-
device_text = str(self.model_manager.sam_model.device).upper()
|
474
|
-
self.status_bar.set_permanent_message(f"Device: {device_text}")
|
475
|
-
else:
|
476
|
-
self.control_panel.set_current_model("Current: Default SAM Model")
|
477
|
-
self._show_error_notification(
|
478
|
-
"Failed to load selected model. Using default."
|
479
|
-
)
|
480
|
-
self.control_panel.model_widget.reset_to_default()
|
481
|
-
self._enable_sam_functionality(False)
|
482
|
-
except Exception as e:
|
483
|
-
self.control_panel.set_current_model("Current: Default SAM Model")
|
484
|
-
self._show_error_notification(f"Error loading model: {str(e)}")
|
485
|
-
self.control_panel.model_widget.reset_to_default()
|
486
|
-
self._enable_sam_functionality(False)
|
487
|
-
|
488
|
-
# Adjustment methods
|
489
|
-
def _set_annotation_size(self, value):
|
490
|
-
"""Set annotation size."""
|
491
|
-
multiplier = value / 10.0
|
492
|
-
self.point_radius = self.settings.point_radius * multiplier
|
493
|
-
self.line_thickness = self.settings.line_thickness * multiplier
|
494
|
-
self.settings.annotation_size_multiplier = multiplier
|
495
|
-
# Update display (implementation would go here)
|
496
|
-
|
497
|
-
def _set_pan_speed(self, value):
|
498
|
-
"""Set pan speed."""
|
499
|
-
self.pan_multiplier = value / 10.0
|
500
|
-
self.settings.pan_multiplier = self.pan_multiplier
|
501
|
-
|
502
|
-
def _set_join_threshold(self, value):
|
503
|
-
"""Set polygon join threshold."""
|
504
|
-
self.polygon_join_threshold = value
|
505
|
-
self.settings.polygon_join_threshold = value
|
506
|
-
|
507
|
-
# File management methods
|
508
|
-
def _open_folder_dialog(self):
|
509
|
-
"""Open folder dialog for images."""
|
510
|
-
folder_path = QFileDialog.getExistingDirectory(self, "Select Image Folder")
|
511
|
-
if folder_path:
|
512
|
-
self.right_panel.set_folder(folder_path, self.file_model)
|
513
|
-
self.viewer.setFocus()
|
514
|
-
|
515
|
-
def _load_selected_image(self, index):
|
516
|
-
"""Load the selected image."""
|
517
|
-
if not index.isValid() or not self.file_model.isDir(index.parent()):
|
518
|
-
return
|
519
|
-
|
520
|
-
self.current_file_index = index
|
521
|
-
path = self.file_model.filePath(index)
|
522
|
-
|
523
|
-
if os.path.isfile(path) and self.file_manager.is_image_file(path):
|
524
|
-
self.current_image_path = path
|
525
|
-
pixmap = QPixmap(self.current_image_path)
|
526
|
-
if not pixmap.isNull():
|
527
|
-
self._reset_state()
|
528
|
-
self.viewer.set_photo(pixmap)
|
529
|
-
if self.model_manager.is_model_available():
|
530
|
-
self.model_manager.sam_model.set_image(self.current_image_path)
|
531
|
-
self.file_manager.load_class_aliases(self.current_image_path)
|
532
|
-
self.file_manager.load_existing_mask(self.current_image_path)
|
533
|
-
self.right_panel.file_tree.setCurrentIndex(index)
|
534
|
-
self._update_all_lists()
|
535
|
-
self.viewer.setFocus()
|
536
|
-
|
537
|
-
def _load_next_image(self):
|
538
|
-
"""Load next image in the file list, with auto-save if enabled."""
|
539
|
-
if not self.current_file_index.isValid():
|
540
|
-
return
|
541
|
-
# Auto-save if enabled
|
542
|
-
if self.control_panel.get_settings().get("auto_save", True):
|
543
|
-
self._save_output_to_npz()
|
544
|
-
parent = self.current_file_index.parent()
|
545
|
-
row = self.current_file_index.row()
|
546
|
-
# Find next valid image file
|
547
|
-
for next_row in range(row + 1, self.file_model.rowCount(parent)):
|
548
|
-
next_index = self.file_model.index(next_row, 0, parent)
|
549
|
-
path = self.file_model.filePath(next_index)
|
550
|
-
if os.path.isfile(path) and self.file_manager.is_image_file(path):
|
551
|
-
self._load_selected_image(next_index)
|
552
|
-
return
|
553
|
-
|
554
|
-
def _load_previous_image(self):
|
555
|
-
"""Load previous image in the file list, with auto-save if enabled."""
|
556
|
-
if not self.current_file_index.isValid():
|
557
|
-
return
|
558
|
-
# Auto-save if enabled
|
559
|
-
if self.control_panel.get_settings().get("auto_save", True):
|
560
|
-
self._save_output_to_npz()
|
561
|
-
parent = self.current_file_index.parent()
|
562
|
-
row = self.current_file_index.row()
|
563
|
-
# Find previous valid image file
|
564
|
-
for prev_row in range(row - 1, -1, -1):
|
565
|
-
prev_index = self.file_model.index(prev_row, 0, parent)
|
566
|
-
path = self.file_model.filePath(prev_index)
|
567
|
-
if os.path.isfile(path) and self.file_manager.is_image_file(path):
|
568
|
-
self._load_selected_image(prev_index)
|
569
|
-
return
|
570
|
-
|
571
|
-
# Segment management methods
|
572
|
-
def _assign_selected_to_class(self):
|
573
|
-
"""Assign selected segments to class."""
|
574
|
-
selected_indices = self.right_panel.get_selected_segment_indices()
|
575
|
-
self.segment_manager.assign_segments_to_class(selected_indices)
|
576
|
-
self._update_all_lists()
|
577
|
-
|
578
|
-
def _delete_selected_segments(self):
|
579
|
-
"""Delete selected segments and remove any highlight overlays."""
|
580
|
-
# Remove highlight overlays before deleting segments
|
581
|
-
if hasattr(self, "highlight_items"):
|
582
|
-
for item in self.highlight_items:
|
583
|
-
self.viewer.scene().removeItem(item)
|
584
|
-
self.highlight_items = []
|
585
|
-
selected_indices = self.right_panel.get_selected_segment_indices()
|
586
|
-
self.segment_manager.delete_segments(selected_indices)
|
587
|
-
self._update_all_lists()
|
588
|
-
|
589
|
-
def _highlight_selected_segments(self):
|
590
|
-
"""Highlight selected segments. In edit mode, use a brighter hover-like effect."""
|
591
|
-
# Remove previous highlight overlays
|
592
|
-
if hasattr(self, "highlight_items"):
|
593
|
-
for item in self.highlight_items:
|
594
|
-
if item.scene():
|
595
|
-
self.viewer.scene().removeItem(item)
|
596
|
-
self.highlight_items = []
|
597
|
-
|
598
|
-
selected_indices = self.right_panel.get_selected_segment_indices()
|
599
|
-
if not selected_indices:
|
600
|
-
return
|
601
|
-
|
602
|
-
for i in selected_indices:
|
603
|
-
seg = self.segment_manager.segments[i]
|
604
|
-
base_color = self._get_color_for_class(seg.get("class_id"))
|
605
|
-
|
606
|
-
if self.mode == "edit":
|
607
|
-
# Use a brighter, hover-like highlight in edit mode
|
608
|
-
highlight_brush = QBrush(
|
609
|
-
QColor(base_color.red(), base_color.green(), base_color.blue(), 170)
|
610
|
-
)
|
611
|
-
else:
|
612
|
-
# Use the standard yellow overlay for selection
|
613
|
-
highlight_brush = QBrush(QColor(255, 255, 0, 180))
|
614
|
-
|
615
|
-
if seg["type"] == "Polygon" and seg.get("vertices"):
|
616
|
-
poly_item = QGraphicsPolygonItem(QPolygonF(seg["vertices"]))
|
617
|
-
poly_item.setBrush(highlight_brush)
|
618
|
-
poly_item.setPen(QPen(Qt.GlobalColor.transparent))
|
619
|
-
poly_item.setZValue(99)
|
620
|
-
self.viewer.scene().addItem(poly_item)
|
621
|
-
self.highlight_items.append(poly_item)
|
622
|
-
elif seg.get("mask") is not None:
|
623
|
-
# For non-polygon types, we still use the mask-to-pixmap approach.
|
624
|
-
# If in edit mode, we could consider skipping non-polygons.
|
625
|
-
if self.mode != "edit":
|
626
|
-
mask = seg.get("mask")
|
627
|
-
pixmap = mask_to_pixmap(mask, (255, 255, 0), alpha=180)
|
628
|
-
highlight_item = self.viewer.scene().addPixmap(pixmap)
|
629
|
-
highlight_item.setZValue(100)
|
630
|
-
self.highlight_items.append(highlight_item)
|
631
|
-
|
632
|
-
def _handle_alias_change(self, class_id, alias):
|
633
|
-
"""Handle class alias change."""
|
634
|
-
if self._updating_lists:
|
635
|
-
return # Prevent recursion
|
636
|
-
self.segment_manager.set_class_alias(class_id, alias)
|
637
|
-
self._update_all_lists()
|
638
|
-
|
639
|
-
def _reassign_class_ids(self):
|
640
|
-
"""Reassign class IDs."""
|
641
|
-
new_order = self.right_panel.get_class_order()
|
642
|
-
self.segment_manager.reassign_class_ids(new_order)
|
643
|
-
self._update_all_lists()
|
644
|
-
|
645
|
-
def _update_segment_table(self):
|
646
|
-
"""Update segment table."""
|
647
|
-
table = self.right_panel.segment_table
|
648
|
-
table.blockSignals(True)
|
649
|
-
selected_indices = self.right_panel.get_selected_segment_indices()
|
650
|
-
table.clearContents()
|
651
|
-
table.setRowCount(0)
|
652
|
-
|
653
|
-
# Get current filter
|
654
|
-
filter_text = self.right_panel.class_filter_combo.currentText()
|
655
|
-
show_all = filter_text == "All Classes"
|
656
|
-
filter_class_id = -1
|
657
|
-
if not show_all:
|
658
|
-
try:
|
659
|
-
# Parse format like "Alias: ID" or "Class ID"
|
660
|
-
if ":" in filter_text:
|
661
|
-
filter_class_id = int(filter_text.split(":")[-1].strip())
|
662
|
-
else:
|
663
|
-
filter_class_id = int(filter_text.split()[-1])
|
664
|
-
except (ValueError, IndexError):
|
665
|
-
show_all = True # If parsing fails, show all
|
666
|
-
|
667
|
-
# Filter segments based on class filter
|
668
|
-
display_segments = []
|
669
|
-
for i, seg in enumerate(self.segment_manager.segments):
|
670
|
-
seg_class_id = seg.get("class_id")
|
671
|
-
should_include = show_all or seg_class_id == filter_class_id
|
672
|
-
if should_include:
|
673
|
-
display_segments.append((i, seg))
|
674
|
-
|
675
|
-
table.setRowCount(len(display_segments))
|
676
|
-
|
677
|
-
# Populate table rows
|
678
|
-
for row, (original_index, seg) in enumerate(display_segments):
|
679
|
-
class_id = seg.get("class_id")
|
680
|
-
color = self._get_color_for_class(class_id)
|
681
|
-
class_id_str = str(class_id) if class_id is not None else "N/A"
|
682
|
-
|
683
|
-
alias_str = "N/A"
|
684
|
-
if class_id is not None:
|
685
|
-
alias_str = self.segment_manager.get_class_alias(class_id)
|
686
|
-
|
687
|
-
# Create table items (1-based segment ID for display)
|
688
|
-
index_item = NumericTableWidgetItem(str(original_index + 1))
|
689
|
-
class_item = NumericTableWidgetItem(class_id_str)
|
690
|
-
alias_item = QTableWidgetItem(alias_str)
|
691
|
-
|
692
|
-
# Set items as non-editable
|
693
|
-
index_item.setFlags(index_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
694
|
-
class_item.setFlags(class_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
695
|
-
alias_item.setFlags(alias_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
696
|
-
|
697
|
-
# Store original index for selection tracking
|
698
|
-
index_item.setData(Qt.ItemDataRole.UserRole, original_index)
|
699
|
-
|
700
|
-
# Set items in table
|
701
|
-
table.setItem(row, 0, index_item)
|
702
|
-
table.setItem(row, 1, class_item)
|
703
|
-
table.setItem(row, 2, alias_item)
|
704
|
-
|
705
|
-
# Set background color based on class
|
706
|
-
for col in range(table.columnCount()):
|
707
|
-
if table.item(row, col):
|
708
|
-
table.item(row, col).setBackground(QBrush(color))
|
709
|
-
|
710
|
-
# Restore selection
|
711
|
-
table.setSortingEnabled(False)
|
712
|
-
for row in range(table.rowCount()):
|
713
|
-
item = table.item(row, 0)
|
714
|
-
if item and item.data(Qt.ItemDataRole.UserRole) in selected_indices:
|
715
|
-
table.selectRow(row)
|
716
|
-
table.setSortingEnabled(True)
|
717
|
-
|
718
|
-
table.blockSignals(False)
|
719
|
-
self.viewer.setFocus()
|
720
|
-
|
721
|
-
# Update active class display
|
722
|
-
active_class = self.segment_manager.get_active_class()
|
723
|
-
self.right_panel.update_active_class_display(active_class)
|
724
|
-
|
725
|
-
def _update_all_lists(self):
|
726
|
-
"""Update all UI lists."""
|
727
|
-
if self._updating_lists:
|
728
|
-
return # Prevent recursion
|
729
|
-
|
730
|
-
self._updating_lists = True
|
731
|
-
try:
|
732
|
-
self._update_class_list()
|
733
|
-
self._update_segment_table()
|
734
|
-
self._update_class_filter()
|
735
|
-
self._display_all_segments()
|
736
|
-
if self.mode == "edit":
|
737
|
-
self._display_edit_handles()
|
738
|
-
else:
|
739
|
-
self._clear_edit_handles()
|
740
|
-
finally:
|
741
|
-
self._updating_lists = False
|
742
|
-
|
743
|
-
def _update_class_list(self):
|
744
|
-
"""Update the class list in the right panel."""
|
745
|
-
class_table = self.right_panel.class_table
|
746
|
-
class_table.blockSignals(True)
|
747
|
-
|
748
|
-
# Get unique class IDs
|
749
|
-
unique_class_ids = self.segment_manager.get_unique_class_ids()
|
750
|
-
|
751
|
-
class_table.clearContents()
|
752
|
-
class_table.setRowCount(len(unique_class_ids))
|
753
|
-
|
754
|
-
for row, cid in enumerate(unique_class_ids):
|
755
|
-
alias_item = QTableWidgetItem(self.segment_manager.get_class_alias(cid))
|
756
|
-
id_item = QTableWidgetItem(str(cid))
|
757
|
-
id_item.setFlags(id_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
758
|
-
|
759
|
-
color = self._get_color_for_class(cid)
|
760
|
-
alias_item.setBackground(QBrush(color))
|
761
|
-
id_item.setBackground(QBrush(color))
|
762
|
-
|
763
|
-
class_table.setItem(row, 0, alias_item)
|
764
|
-
class_table.setItem(row, 1, id_item)
|
765
|
-
|
766
|
-
# Update active class display BEFORE re-enabling signals
|
767
|
-
active_class = self.segment_manager.get_active_class()
|
768
|
-
self.right_panel.update_active_class_display(active_class)
|
769
|
-
|
770
|
-
class_table.blockSignals(False)
|
771
|
-
|
772
|
-
def _update_class_filter(self):
|
773
|
-
"""Update the class filter combo box."""
|
774
|
-
combo = self.right_panel.class_filter_combo
|
775
|
-
current_text = combo.currentText()
|
776
|
-
|
777
|
-
combo.blockSignals(True)
|
778
|
-
combo.clear()
|
779
|
-
combo.addItem("All Classes")
|
780
|
-
|
781
|
-
# Add class options
|
782
|
-
unique_class_ids = self.segment_manager.get_unique_class_ids()
|
783
|
-
for class_id in unique_class_ids:
|
784
|
-
alias = self.segment_manager.get_class_alias(class_id)
|
785
|
-
display_text = f"{alias}: {class_id}" if alias else f"Class {class_id}"
|
786
|
-
combo.addItem(display_text)
|
787
|
-
|
788
|
-
# Restore selection if possible
|
789
|
-
index = combo.findText(current_text)
|
790
|
-
if index >= 0:
|
791
|
-
combo.setCurrentIndex(index)
|
792
|
-
else:
|
793
|
-
combo.setCurrentIndex(0)
|
794
|
-
|
795
|
-
combo.blockSignals(False)
|
796
|
-
|
797
|
-
def _display_all_segments(self):
|
798
|
-
"""Display all segments on the viewer."""
|
799
|
-
# Clear existing segment items
|
800
|
-
for
|
801
|
-
for item in items:
|
802
|
-
if item.scene():
|
803
|
-
self.viewer.scene().removeItem(item)
|
804
|
-
self.segment_items.clear()
|
805
|
-
self._clear_edit_handles()
|
806
|
-
|
807
|
-
# Display segments from segment manager
|
808
|
-
for i, segment in enumerate(self.segment_manager.segments):
|
809
|
-
self.segment_items[i] = []
|
810
|
-
class_id = segment.get("class_id")
|
811
|
-
base_color = self._get_color_for_class(class_id)
|
812
|
-
|
813
|
-
if segment["type"] == "Polygon" and segment.get("vertices"):
|
814
|
-
poly_item = HoverablePolygonItem(QPolygonF(segment["vertices"]))
|
815
|
-
default_brush = QBrush(
|
816
|
-
QColor(base_color.red(), base_color.green(), base_color.blue(), 70)
|
817
|
-
)
|
818
|
-
hover_brush = QBrush(
|
819
|
-
QColor(base_color.red(), base_color.green(), base_color.blue(), 170)
|
820
|
-
)
|
821
|
-
poly_item.set_brushes(default_brush, hover_brush)
|
822
|
-
poly_item.setPen(QPen(Qt.GlobalColor.transparent))
|
823
|
-
self.viewer.scene().addItem(poly_item)
|
824
|
-
self.segment_items[i].append(poly_item)
|
825
|
-
elif segment.get("mask") is not None:
|
826
|
-
default_pixmap = mask_to_pixmap(
|
827
|
-
segment["mask"], base_color.getRgb()[:3], alpha=70
|
828
|
-
)
|
829
|
-
hover_pixmap = mask_to_pixmap(
|
830
|
-
segment["mask"], base_color.getRgb()[:3], alpha=170
|
831
|
-
)
|
832
|
-
pixmap_item = HoverablePixmapItem()
|
833
|
-
pixmap_item.set_pixmaps(default_pixmap, hover_pixmap)
|
834
|
-
self.viewer.scene().addItem(pixmap_item)
|
835
|
-
pixmap_item.setZValue(i + 1)
|
836
|
-
self.segment_items[i].append(pixmap_item)
|
837
|
-
|
838
|
-
# Event handlers
|
839
|
-
def _handle_escape_press(self):
|
840
|
-
"""Handle escape key press."""
|
841
|
-
self.right_panel.clear_selections()
|
842
|
-
self.clear_all_points()
|
843
|
-
self.viewer.setFocus()
|
844
|
-
|
845
|
-
def _handle_space_press(self):
|
846
|
-
"""Handle space key press."""
|
847
|
-
if self.mode == "polygon" and self.polygon_points:
|
848
|
-
self._finalize_polygon()
|
849
|
-
else:
|
850
|
-
self._save_current_segment()
|
851
|
-
|
852
|
-
def _handle_enter_press(self):
|
853
|
-
"""Handle enter key press."""
|
854
|
-
if self.mode == "polygon" and self.polygon_points:
|
855
|
-
self._finalize_polygon()
|
856
|
-
else:
|
857
|
-
self._save_output_to_npz()
|
858
|
-
|
859
|
-
def _save_current_segment(self):
|
860
|
-
"""Save current SAM segment."""
|
861
|
-
if (
|
862
|
-
self.mode != "sam_points"
|
863
|
-
or not hasattr(self, "preview_mask_item")
|
864
|
-
or not self.preview_mask_item
|
865
|
-
or not self.model_manager.is_model_available()
|
866
|
-
):
|
867
|
-
return
|
868
|
-
|
869
|
-
mask = self.model_manager.sam_model.predict(
|
870
|
-
self.positive_points, self.negative_points
|
871
|
-
)
|
872
|
-
if mask is not None:
|
873
|
-
new_segment = {
|
874
|
-
"mask": mask,
|
875
|
-
"type": "SAM",
|
876
|
-
"vertices": None,
|
877
|
-
}
|
878
|
-
self.segment_manager.add_segment(new_segment)
|
879
|
-
# Record the action for undo
|
880
|
-
self.action_history.append(
|
881
|
-
{
|
882
|
-
"type": "add_segment",
|
883
|
-
"segment_index": len(self.segment_manager.segments) - 1,
|
884
|
-
}
|
885
|
-
)
|
886
|
-
|
887
|
-
self.
|
888
|
-
|
889
|
-
|
890
|
-
|
891
|
-
|
892
|
-
|
893
|
-
|
894
|
-
|
895
|
-
|
896
|
-
|
897
|
-
"
|
898
|
-
|
899
|
-
|
900
|
-
|
901
|
-
self.
|
902
|
-
|
903
|
-
|
904
|
-
|
905
|
-
|
906
|
-
|
907
|
-
|
908
|
-
|
909
|
-
|
910
|
-
self.
|
911
|
-
|
912
|
-
|
913
|
-
|
914
|
-
|
915
|
-
|
916
|
-
|
917
|
-
|
918
|
-
|
919
|
-
|
920
|
-
|
921
|
-
|
922
|
-
|
923
|
-
|
924
|
-
|
925
|
-
|
926
|
-
|
927
|
-
|
928
|
-
|
929
|
-
|
930
|
-
|
931
|
-
|
932
|
-
)
|
933
|
-
|
934
|
-
|
935
|
-
|
936
|
-
|
937
|
-
|
938
|
-
self.
|
939
|
-
|
940
|
-
|
941
|
-
|
942
|
-
|
943
|
-
|
944
|
-
|
945
|
-
|
946
|
-
|
947
|
-
|
948
|
-
|
949
|
-
|
950
|
-
|
951
|
-
|
952
|
-
|
953
|
-
|
954
|
-
|
955
|
-
|
956
|
-
|
957
|
-
|
958
|
-
|
959
|
-
self.
|
960
|
-
|
961
|
-
|
962
|
-
|
963
|
-
self.
|
964
|
-
|
965
|
-
|
966
|
-
|
967
|
-
|
968
|
-
|
969
|
-
|
970
|
-
|
971
|
-
class_labels = [
|
972
|
-
|
973
|
-
|
974
|
-
|
975
|
-
)
|
976
|
-
|
977
|
-
|
978
|
-
|
979
|
-
|
980
|
-
|
981
|
-
|
982
|
-
|
983
|
-
|
984
|
-
|
985
|
-
|
986
|
-
|
987
|
-
|
988
|
-
|
989
|
-
|
990
|
-
|
991
|
-
|
992
|
-
|
993
|
-
|
994
|
-
|
995
|
-
|
996
|
-
|
997
|
-
|
998
|
-
|
999
|
-
|
1000
|
-
|
1001
|
-
|
1002
|
-
|
1003
|
-
|
1004
|
-
|
1005
|
-
|
1006
|
-
|
1007
|
-
|
1008
|
-
|
1009
|
-
|
1010
|
-
|
1011
|
-
|
1012
|
-
|
1013
|
-
|
1014
|
-
|
1015
|
-
|
1016
|
-
|
1017
|
-
|
1018
|
-
|
1019
|
-
|
1020
|
-
|
1021
|
-
|
1022
|
-
|
1023
|
-
|
1024
|
-
|
1025
|
-
self.
|
1026
|
-
self.
|
1027
|
-
|
1028
|
-
|
1029
|
-
|
1030
|
-
|
1031
|
-
|
1032
|
-
|
1033
|
-
|
1034
|
-
self.
|
1035
|
-
|
1036
|
-
|
1037
|
-
|
1038
|
-
|
1039
|
-
|
1040
|
-
|
1041
|
-
|
1042
|
-
|
1043
|
-
|
1044
|
-
|
1045
|
-
|
1046
|
-
|
1047
|
-
|
1048
|
-
|
1049
|
-
|
1050
|
-
|
1051
|
-
self.
|
1052
|
-
|
1053
|
-
|
1054
|
-
|
1055
|
-
|
1056
|
-
|
1057
|
-
|
1058
|
-
|
1059
|
-
|
1060
|
-
|
1061
|
-
|
1062
|
-
|
1063
|
-
|
1064
|
-
|
1065
|
-
|
1066
|
-
|
1067
|
-
|
1068
|
-
self.
|
1069
|
-
|
1070
|
-
|
1071
|
-
|
1072
|
-
for
|
1073
|
-
|
1074
|
-
|
1075
|
-
|
1076
|
-
|
1077
|
-
|
1078
|
-
self.
|
1079
|
-
|
1080
|
-
def
|
1081
|
-
"""
|
1082
|
-
self.
|
1083
|
-
|
1084
|
-
|
1085
|
-
|
1086
|
-
self.
|
1087
|
-
|
1088
|
-
|
1089
|
-
|
1090
|
-
self.
|
1091
|
-
|
1092
|
-
|
1093
|
-
|
1094
|
-
|
1095
|
-
|
1096
|
-
|
1097
|
-
|
1098
|
-
|
1099
|
-
|
1100
|
-
|
1101
|
-
|
1102
|
-
|
1103
|
-
|
1104
|
-
|
1105
|
-
|
1106
|
-
|
1107
|
-
|
1108
|
-
|
1109
|
-
|
1110
|
-
|
1111
|
-
|
1112
|
-
|
1113
|
-
|
1114
|
-
|
1115
|
-
|
1116
|
-
|
1117
|
-
|
1118
|
-
|
1119
|
-
|
1120
|
-
|
1121
|
-
|
1122
|
-
self.
|
1123
|
-
|
1124
|
-
|
1125
|
-
|
1126
|
-
|
1127
|
-
)
|
1128
|
-
|
1129
|
-
|
1130
|
-
|
1131
|
-
|
1132
|
-
|
1133
|
-
|
1134
|
-
|
1135
|
-
|
1136
|
-
self.
|
1137
|
-
|
1138
|
-
|
1139
|
-
|
1140
|
-
|
1141
|
-
|
1142
|
-
|
1143
|
-
|
1144
|
-
|
1145
|
-
|
1146
|
-
|
1147
|
-
|
1148
|
-
|
1149
|
-
|
1150
|
-
|
1151
|
-
|
1152
|
-
|
1153
|
-
|
1154
|
-
|
1155
|
-
|
1156
|
-
|
1157
|
-
|
1158
|
-
|
1159
|
-
|
1160
|
-
|
1161
|
-
|
1162
|
-
|
1163
|
-
|
1164
|
-
|
1165
|
-
|
1166
|
-
|
1167
|
-
|
1168
|
-
|
1169
|
-
|
1170
|
-
|
1171
|
-
|
1172
|
-
|
1173
|
-
|
1174
|
-
|
1175
|
-
|
1176
|
-
|
1177
|
-
self.
|
1178
|
-
|
1179
|
-
|
1180
|
-
|
1181
|
-
|
1182
|
-
|
1183
|
-
|
1184
|
-
|
1185
|
-
|
1186
|
-
|
1187
|
-
|
1188
|
-
|
1189
|
-
|
1190
|
-
|
1191
|
-
|
1192
|
-
|
1193
|
-
|
1194
|
-
|
1195
|
-
|
1196
|
-
|
1197
|
-
|
1198
|
-
|
1199
|
-
|
1200
|
-
|
1201
|
-
|
1202
|
-
|
1203
|
-
|
1204
|
-
|
1205
|
-
|
1206
|
-
|
1207
|
-
|
1208
|
-
|
1209
|
-
|
1210
|
-
|
1211
|
-
|
1212
|
-
|
1213
|
-
|
1214
|
-
|
1215
|
-
|
1216
|
-
|
1217
|
-
|
1218
|
-
|
1219
|
-
|
1220
|
-
|
1221
|
-
|
1222
|
-
|
1223
|
-
|
1224
|
-
|
1225
|
-
|
1226
|
-
|
1227
|
-
|
1228
|
-
|
1229
|
-
|
1230
|
-
|
1231
|
-
|
1232
|
-
|
1233
|
-
|
1234
|
-
|
1235
|
-
|
1236
|
-
self.
|
1237
|
-
|
1238
|
-
|
1239
|
-
|
1240
|
-
|
1241
|
-
|
1242
|
-
|
1243
|
-
|
1244
|
-
|
1245
|
-
|
1246
|
-
|
1247
|
-
|
1248
|
-
|
1249
|
-
|
1250
|
-
self.
|
1251
|
-
|
1252
|
-
|
1253
|
-
|
1254
|
-
|
1255
|
-
|
1256
|
-
|
1257
|
-
|
1258
|
-
self.
|
1259
|
-
|
1260
|
-
|
1261
|
-
|
1262
|
-
|
1263
|
-
|
1264
|
-
|
1265
|
-
|
1266
|
-
def
|
1267
|
-
"""
|
1268
|
-
|
1269
|
-
|
1270
|
-
|
1271
|
-
|
1272
|
-
|
1273
|
-
|
1274
|
-
|
1275
|
-
|
1276
|
-
|
1277
|
-
|
1278
|
-
|
1279
|
-
|
1280
|
-
|
1281
|
-
|
1282
|
-
|
1283
|
-
|
1284
|
-
|
1285
|
-
|
1286
|
-
self.
|
1287
|
-
|
1288
|
-
|
1289
|
-
|
1290
|
-
|
1291
|
-
|
1292
|
-
|
1293
|
-
|
1294
|
-
|
1295
|
-
|
1296
|
-
|
1297
|
-
|
1298
|
-
|
1299
|
-
|
1300
|
-
|
1301
|
-
|
1302
|
-
|
1303
|
-
|
1304
|
-
|
1305
|
-
|
1306
|
-
|
1307
|
-
|
1308
|
-
|
1309
|
-
|
1310
|
-
|
1311
|
-
|
1312
|
-
|
1313
|
-
|
1314
|
-
|
1315
|
-
#
|
1316
|
-
if
|
1317
|
-
|
1318
|
-
|
1319
|
-
|
1320
|
-
|
1321
|
-
|
1322
|
-
|
1323
|
-
|
1324
|
-
|
1325
|
-
|
1326
|
-
|
1327
|
-
|
1328
|
-
|
1329
|
-
|
1330
|
-
|
1331
|
-
|
1332
|
-
|
1333
|
-
|
1334
|
-
|
1335
|
-
|
1336
|
-
|
1337
|
-
|
1338
|
-
|
1339
|
-
|
1340
|
-
|
1341
|
-
self.
|
1342
|
-
|
1343
|
-
|
1344
|
-
|
1345
|
-
|
1346
|
-
|
1347
|
-
|
1348
|
-
|
1349
|
-
"
|
1350
|
-
|
1351
|
-
|
1352
|
-
|
1353
|
-
|
1354
|
-
|
1355
|
-
|
1356
|
-
|
1357
|
-
|
1358
|
-
|
1359
|
-
|
1360
|
-
|
1361
|
-
|
1362
|
-
|
1363
|
-
|
1364
|
-
|
1365
|
-
|
1366
|
-
|
1367
|
-
|
1368
|
-
|
1369
|
-
|
1370
|
-
|
1371
|
-
|
1372
|
-
|
1373
|
-
|
1374
|
-
|
1375
|
-
|
1376
|
-
|
1377
|
-
|
1378
|
-
|
1379
|
-
|
1380
|
-
|
1381
|
-
|
1382
|
-
|
1383
|
-
|
1384
|
-
|
1385
|
-
|
1386
|
-
|
1387
|
-
|
1388
|
-
|
1389
|
-
|
1390
|
-
|
1391
|
-
|
1392
|
-
|
1393
|
-
|
1394
|
-
|
1395
|
-
|
1396
|
-
|
1397
|
-
|
1398
|
-
|
1399
|
-
|
1400
|
-
|
1401
|
-
|
1402
|
-
|
1403
|
-
|
1404
|
-
|
1405
|
-
|
1406
|
-
|
1407
|
-
|
1408
|
-
|
1409
|
-
|
1410
|
-
|
1411
|
-
|
1412
|
-
|
1413
|
-
|
1414
|
-
|
1415
|
-
|
1416
|
-
|
1417
|
-
|
1418
|
-
|
1419
|
-
|
1420
|
-
|
1421
|
-
|
1422
|
-
|
1423
|
-
|
1424
|
-
|
1425
|
-
|
1426
|
-
|
1427
|
-
|
1428
|
-
|
1429
|
-
|
1430
|
-
|
1431
|
-
|
1432
|
-
if
|
1433
|
-
|
1434
|
-
|
1435
|
-
|
1436
|
-
|
1437
|
-
|
1438
|
-
|
1439
|
-
|
1440
|
-
|
1441
|
-
|
1442
|
-
|
1443
|
-
|
1444
|
-
|
1445
|
-
|
1446
|
-
|
1447
|
-
|
1448
|
-
|
1449
|
-
|
1450
|
-
|
1451
|
-
|
1452
|
-
|
1453
|
-
|
1454
|
-
|
1455
|
-
|
1456
|
-
|
1457
|
-
|
1458
|
-
|
1459
|
-
|
1460
|
-
|
1461
|
-
|
1462
|
-
|
1463
|
-
|
1464
|
-
|
1465
|
-
|
1466
|
-
|
1467
|
-
|
1468
|
-
|
1469
|
-
|
1470
|
-
|
1471
|
-
|
1472
|
-
|
1473
|
-
|
1474
|
-
|
1475
|
-
|
1476
|
-
|
1477
|
-
|
1478
|
-
|
1479
|
-
|
1480
|
-
|
1481
|
-
|
1482
|
-
|
1483
|
-
|
1484
|
-
|
1485
|
-
|
1486
|
-
|
1487
|
-
|
1488
|
-
|
1489
|
-
|
1490
|
-
|
1491
|
-
|
1492
|
-
|
1493
|
-
|
1494
|
-
|
1495
|
-
self.
|
1496
|
-
|
1497
|
-
|
1498
|
-
|
1499
|
-
|
1500
|
-
|
1501
|
-
|
1502
|
-
|
1503
|
-
|
1504
|
-
|
1505
|
-
|
1506
|
-
|
1507
|
-
|
1508
|
-
|
1509
|
-
|
1510
|
-
|
1511
|
-
|
1512
|
-
|
1513
|
-
|
1514
|
-
|
1515
|
-
|
1516
|
-
|
1517
|
-
|
1518
|
-
|
1519
|
-
|
1520
|
-
|
1521
|
-
|
1522
|
-
|
1523
|
-
|
1524
|
-
|
1525
|
-
|
1526
|
-
|
1527
|
-
|
1528
|
-
|
1529
|
-
|
1530
|
-
|
1531
|
-
|
1532
|
-
|
1533
|
-
|
1534
|
-
|
1535
|
-
|
1536
|
-
|
1537
|
-
|
1538
|
-
|
1539
|
-
|
1540
|
-
|
1541
|
-
|
1542
|
-
|
1543
|
-
|
1544
|
-
|
1545
|
-
|
1546
|
-
|
1547
|
-
|
1548
|
-
|
1549
|
-
|
1550
|
-
|
1551
|
-
|
1552
|
-
|
1553
|
-
|
1554
|
-
self.
|
1555
|
-
|
1556
|
-
|
1557
|
-
|
1558
|
-
|
1559
|
-
|
1560
|
-
|
1561
|
-
|
1562
|
-
|
1563
|
-
|
1564
|
-
|
1565
|
-
|
1566
|
-
|
1567
|
-
|
1568
|
-
self.
|
1569
|
-
|
1570
|
-
|
1571
|
-
|
1572
|
-
|
1573
|
-
|
1574
|
-
|
1575
|
-
|
1576
|
-
|
1577
|
-
|
1578
|
-
|
1579
|
-
|
1580
|
-
|
1581
|
-
|
1582
|
-
|
1583
|
-
|
1584
|
-
|
1585
|
-
|
1586
|
-
|
1587
|
-
|
1588
|
-
|
1589
|
-
|
1590
|
-
|
1591
|
-
|
1592
|
-
|
1593
|
-
|
1594
|
-
|
1595
|
-
|
1596
|
-
|
1597
|
-
|
1598
|
-
|
1599
|
-
|
1600
|
-
|
1601
|
-
|
1602
|
-
|
1603
|
-
|
1604
|
-
|
1605
|
-
|
1606
|
-
|
1607
|
-
|
1608
|
-
|
1609
|
-
|
1610
|
-
|
1611
|
-
|
1612
|
-
|
1613
|
-
|
1614
|
-
|
1615
|
-
|
1616
|
-
|
1617
|
-
|
1618
|
-
|
1619
|
-
|
1620
|
-
|
1621
|
-
|
1622
|
-
|
1623
|
-
|
1624
|
-
|
1625
|
-
|
1626
|
-
|
1627
|
-
|
1628
|
-
|
1629
|
-
|
1630
|
-
|
1631
|
-
|
1632
|
-
|
1633
|
-
|
1634
|
-
|
1635
|
-
|
1636
|
-
|
1637
|
-
|
1638
|
-
|
1639
|
-
|
1640
|
-
|
1641
|
-
|
1642
|
-
|
1643
|
-
|
1644
|
-
|
1645
|
-
|
1646
|
-
|
1647
|
-
|
1648
|
-
|
1649
|
-
|
1650
|
-
|
1651
|
-
|
1652
|
-
|
1653
|
-
|
1654
|
-
|
1655
|
-
|
1656
|
-
|
1657
|
-
|
1658
|
-
|
1659
|
-
|
1
|
+
"""Main application window."""
|
2
|
+
|
3
|
+
import os
|
4
|
+
|
5
|
+
import cv2
|
6
|
+
import numpy as np
|
7
|
+
from PyQt6.QtCore import QModelIndex, QPointF, Qt, QTimer, pyqtSignal
|
8
|
+
from PyQt6.QtGui import (
|
9
|
+
QBrush,
|
10
|
+
QColor,
|
11
|
+
QIcon,
|
12
|
+
QKeySequence,
|
13
|
+
QPen,
|
14
|
+
QPixmap,
|
15
|
+
QPolygonF,
|
16
|
+
QShortcut,
|
17
|
+
)
|
18
|
+
from PyQt6.QtWidgets import (
|
19
|
+
QApplication,
|
20
|
+
QDialog,
|
21
|
+
QFileDialog,
|
22
|
+
QGraphicsEllipseItem,
|
23
|
+
QGraphicsLineItem,
|
24
|
+
QGraphicsPolygonItem,
|
25
|
+
QMainWindow,
|
26
|
+
QSplitter,
|
27
|
+
QTableWidgetItem,
|
28
|
+
QTableWidgetSelectionRange,
|
29
|
+
QVBoxLayout,
|
30
|
+
QWidget,
|
31
|
+
)
|
32
|
+
|
33
|
+
from ..config import HotkeyManager, Paths, Settings
|
34
|
+
from ..core import FileManager, ModelManager, SegmentManager
|
35
|
+
from ..utils import CustomFileSystemModel, mask_to_pixmap
|
36
|
+
from .control_panel import ControlPanel
|
37
|
+
from .editable_vertex import EditableVertexItem
|
38
|
+
from .hotkey_dialog import HotkeyDialog
|
39
|
+
from .hoverable_pixelmap_item import HoverablePixmapItem
|
40
|
+
from .hoverable_polygon_item import HoverablePolygonItem
|
41
|
+
from .numeric_table_widget_item import NumericTableWidgetItem
|
42
|
+
from .photo_viewer import PhotoViewer
|
43
|
+
from .right_panel import RightPanel
|
44
|
+
from .widgets import StatusBar
|
45
|
+
|
46
|
+
|
47
|
+
class PanelPopoutWindow(QDialog):
|
48
|
+
"""Pop-out window for draggable panels."""
|
49
|
+
|
50
|
+
panel_closed = pyqtSignal(QWidget) # Signal emitted when panel window is closed
|
51
|
+
|
52
|
+
def __init__(self, panel_widget, title="Panel", parent=None):
|
53
|
+
super().__init__(parent)
|
54
|
+
self.panel_widget = panel_widget
|
55
|
+
self.setWindowTitle(title)
|
56
|
+
self.setWindowFlags(Qt.WindowType.Window) # Allow moving to other monitors
|
57
|
+
|
58
|
+
# Make window resizable
|
59
|
+
self.setMinimumSize(200, 300)
|
60
|
+
self.resize(400, 600)
|
61
|
+
|
62
|
+
# Set up layout
|
63
|
+
layout = QVBoxLayout(self)
|
64
|
+
layout.setContentsMargins(5, 5, 5, 5)
|
65
|
+
layout.addWidget(panel_widget)
|
66
|
+
|
67
|
+
# Store original parent for restoration
|
68
|
+
self.original_parent = parent
|
69
|
+
|
70
|
+
def closeEvent(self, event):
|
71
|
+
"""Handle window close - emit signal to return panel to main window."""
|
72
|
+
self.panel_closed.emit(self.panel_widget)
|
73
|
+
super().closeEvent(event)
|
74
|
+
|
75
|
+
|
76
|
+
class MainWindow(QMainWindow):
|
77
|
+
"""Main application window."""
|
78
|
+
|
79
|
+
def __init__(self):
|
80
|
+
super().__init__()
|
81
|
+
|
82
|
+
print("[3/20] Starting LazyLabel...")
|
83
|
+
print("[4/20] Loading configuration and settings...")
|
84
|
+
|
85
|
+
# Initialize configuration
|
86
|
+
self.paths = Paths()
|
87
|
+
self.settings = Settings.load_from_file(str(self.paths.settings_file))
|
88
|
+
self.hotkey_manager = HotkeyManager(str(self.paths.config_dir))
|
89
|
+
|
90
|
+
print("[5/20] Initializing core managers...")
|
91
|
+
|
92
|
+
# Initialize managers
|
93
|
+
self.segment_manager = SegmentManager()
|
94
|
+
self.model_manager = ModelManager(self.paths)
|
95
|
+
self.file_manager = FileManager(self.segment_manager)
|
96
|
+
|
97
|
+
print("[6/20] Setting up user interface...")
|
98
|
+
|
99
|
+
# Initialize UI state
|
100
|
+
self.mode = "sam_points"
|
101
|
+
self.previous_mode = "sam_points"
|
102
|
+
self.current_image_path = None
|
103
|
+
self.current_file_index = QModelIndex()
|
104
|
+
|
105
|
+
# Panel pop-out state
|
106
|
+
self.left_panel_popout = None
|
107
|
+
self.right_panel_popout = None
|
108
|
+
|
109
|
+
# Annotation state
|
110
|
+
self.point_radius = self.settings.point_radius
|
111
|
+
self.line_thickness = self.settings.line_thickness
|
112
|
+
self.pan_multiplier = self.settings.pan_multiplier
|
113
|
+
self.polygon_join_threshold = self.settings.polygon_join_threshold
|
114
|
+
|
115
|
+
# Drawing state
|
116
|
+
self.point_items, self.positive_points, self.negative_points = [], [], []
|
117
|
+
self.polygon_points, self.polygon_preview_items = [], []
|
118
|
+
self.rubber_band_line = None
|
119
|
+
self.preview_mask_item = None
|
120
|
+
self.segments, self.segment_items, self.highlight_items = [], {}, []
|
121
|
+
self.edit_handles = []
|
122
|
+
self.is_dragging_polygon, self.drag_start_pos, self.drag_initial_vertices = (
|
123
|
+
False,
|
124
|
+
None,
|
125
|
+
{},
|
126
|
+
)
|
127
|
+
self.action_history = []
|
128
|
+
self.redo_history = []
|
129
|
+
|
130
|
+
# Update state flags to prevent recursion
|
131
|
+
self._updating_lists = False
|
132
|
+
|
133
|
+
self._setup_ui()
|
134
|
+
self._setup_model()
|
135
|
+
|
136
|
+
print("[17/20] Connecting UI signals and shortcuts...")
|
137
|
+
self._setup_connections()
|
138
|
+
self._setup_shortcuts()
|
139
|
+
self._load_settings()
|
140
|
+
|
141
|
+
print("[18/20] LazyLabel initialization complete!")
|
142
|
+
|
143
|
+
def _setup_ui(self):
|
144
|
+
"""Setup the user interface."""
|
145
|
+
self.setWindowTitle("LazyLabel by DNC")
|
146
|
+
self.setGeometry(
|
147
|
+
50, 50, self.settings.window_width, self.settings.window_height
|
148
|
+
)
|
149
|
+
|
150
|
+
# Set window icon
|
151
|
+
if self.paths.logo_path.exists():
|
152
|
+
self.setWindowIcon(QIcon(str(self.paths.logo_path)))
|
153
|
+
|
154
|
+
# Create panels
|
155
|
+
self.control_panel = ControlPanel()
|
156
|
+
self.right_panel = RightPanel()
|
157
|
+
self.viewer = PhotoViewer(self)
|
158
|
+
self.viewer.setMouseTracking(True)
|
159
|
+
|
160
|
+
# Setup file model
|
161
|
+
self.file_model = CustomFileSystemModel()
|
162
|
+
self.right_panel.setup_file_model(self.file_model)
|
163
|
+
|
164
|
+
# Create status bar
|
165
|
+
self.status_bar = StatusBar()
|
166
|
+
self.setStatusBar(self.status_bar)
|
167
|
+
|
168
|
+
# Create horizontal splitter for main panels
|
169
|
+
self.main_splitter = QSplitter(Qt.Orientation.Horizontal)
|
170
|
+
self.main_splitter.addWidget(self.control_panel)
|
171
|
+
self.main_splitter.addWidget(self.viewer)
|
172
|
+
self.main_splitter.addWidget(self.right_panel)
|
173
|
+
|
174
|
+
# Set minimum sizes for panels to prevent shrinking below preferred width
|
175
|
+
self.control_panel.setMinimumWidth(self.control_panel.preferred_width)
|
176
|
+
self.right_panel.setMinimumWidth(self.right_panel.preferred_width)
|
177
|
+
|
178
|
+
# Set splitter sizes - give most space to viewer
|
179
|
+
self.main_splitter.setSizes([250, 800, 350])
|
180
|
+
self.main_splitter.setStretchFactor(0, 0) # Control panel doesn't stretch
|
181
|
+
self.main_splitter.setStretchFactor(1, 1) # Viewer stretches
|
182
|
+
self.main_splitter.setStretchFactor(2, 0) # Right panel doesn't stretch
|
183
|
+
|
184
|
+
# Set splitter child sizes policy
|
185
|
+
self.main_splitter.setChildrenCollapsible(True)
|
186
|
+
|
187
|
+
# Connect splitter signals for intelligent expand/collapse
|
188
|
+
self.main_splitter.splitterMoved.connect(self._handle_splitter_moved)
|
189
|
+
|
190
|
+
# Main vertical layout to accommodate status bar
|
191
|
+
main_layout = QVBoxLayout()
|
192
|
+
main_layout.setContentsMargins(0, 0, 0, 0)
|
193
|
+
main_layout.setSpacing(0)
|
194
|
+
main_layout.addWidget(self.main_splitter, 1)
|
195
|
+
|
196
|
+
central_widget = QWidget()
|
197
|
+
central_widget.setLayout(main_layout)
|
198
|
+
self.setCentralWidget(central_widget)
|
199
|
+
|
200
|
+
def _setup_model(self):
|
201
|
+
"""Setup the SAM model."""
|
202
|
+
print("[7/20] Initializing SAM model (this may take a moment)...")
|
203
|
+
|
204
|
+
sam_model = self.model_manager.initialize_default_model(
|
205
|
+
self.settings.default_model_type
|
206
|
+
)
|
207
|
+
|
208
|
+
if sam_model and sam_model.is_loaded:
|
209
|
+
device_text = str(sam_model.device).upper()
|
210
|
+
print(f"[14/20] SAM model loaded successfully on {device_text}")
|
211
|
+
self.status_bar.set_permanent_message(f"Device: {device_text}")
|
212
|
+
self._enable_sam_functionality(True)
|
213
|
+
elif sam_model is None:
|
214
|
+
print(
|
215
|
+
"[14/20] SAM model initialization failed. Point mode will be disabled."
|
216
|
+
)
|
217
|
+
self.status_bar.set_permanent_message("Model initialization failed")
|
218
|
+
self._enable_sam_functionality(False)
|
219
|
+
else:
|
220
|
+
print("[14/20] SAM model failed to load. Point mode will be disabled.")
|
221
|
+
self.status_bar.set_permanent_message("Model loading failed")
|
222
|
+
self._enable_sam_functionality(False)
|
223
|
+
|
224
|
+
print("[15/20] Scanning available models...")
|
225
|
+
|
226
|
+
# Setup model change callback
|
227
|
+
self.model_manager.on_model_changed = self.control_panel.set_current_model
|
228
|
+
|
229
|
+
# Initialize models list
|
230
|
+
models = self.model_manager.get_available_models(str(self.paths.models_dir))
|
231
|
+
self.control_panel.populate_models(models)
|
232
|
+
|
233
|
+
if models:
|
234
|
+
print(f"[16/20] Found {len(models)} model(s) in models directory")
|
235
|
+
|
236
|
+
def _enable_sam_functionality(self, enabled: bool):
|
237
|
+
"""Enable or disable SAM point functionality."""
|
238
|
+
self.control_panel.set_sam_mode_enabled(enabled)
|
239
|
+
if not enabled and self.mode == "sam_points":
|
240
|
+
# Switch to polygon mode if SAM is disabled and we're in SAM mode
|
241
|
+
self.set_polygon_mode()
|
242
|
+
|
243
|
+
def _setup_connections(self):
|
244
|
+
"""Setup signal connections."""
|
245
|
+
# Control panel connections
|
246
|
+
self.control_panel.sam_mode_requested.connect(self.set_sam_mode)
|
247
|
+
self.control_panel.polygon_mode_requested.connect(self.set_polygon_mode)
|
248
|
+
self.control_panel.selection_mode_requested.connect(self.toggle_selection_mode)
|
249
|
+
self.control_panel.clear_points_requested.connect(self.clear_all_points)
|
250
|
+
self.control_panel.fit_view_requested.connect(self.viewer.fitInView)
|
251
|
+
self.control_panel.hotkeys_requested.connect(self._show_hotkey_dialog)
|
252
|
+
|
253
|
+
# Model management
|
254
|
+
self.control_panel.browse_models_requested.connect(self._browse_models_folder)
|
255
|
+
self.control_panel.refresh_models_requested.connect(self._refresh_models_list)
|
256
|
+
self.control_panel.model_selected.connect(self._load_selected_model)
|
257
|
+
|
258
|
+
# Adjustments
|
259
|
+
self.control_panel.annotation_size_changed.connect(self._set_annotation_size)
|
260
|
+
self.control_panel.pan_speed_changed.connect(self._set_pan_speed)
|
261
|
+
self.control_panel.join_threshold_changed.connect(self._set_join_threshold)
|
262
|
+
|
263
|
+
# Right panel connections
|
264
|
+
self.right_panel.open_folder_requested.connect(self._open_folder_dialog)
|
265
|
+
self.right_panel.image_selected.connect(self._load_selected_image)
|
266
|
+
self.right_panel.merge_selection_requested.connect(
|
267
|
+
self._assign_selected_to_class
|
268
|
+
)
|
269
|
+
self.right_panel.delete_selection_requested.connect(
|
270
|
+
self._delete_selected_segments
|
271
|
+
)
|
272
|
+
self.right_panel.segments_selection_changed.connect(
|
273
|
+
self._highlight_selected_segments
|
274
|
+
)
|
275
|
+
self.right_panel.class_alias_changed.connect(self._handle_alias_change)
|
276
|
+
self.right_panel.reassign_classes_requested.connect(self._reassign_class_ids)
|
277
|
+
self.right_panel.class_filter_changed.connect(self._update_segment_table)
|
278
|
+
self.right_panel.class_toggled.connect(self._handle_class_toggle)
|
279
|
+
|
280
|
+
# Panel pop-out functionality
|
281
|
+
self.control_panel.pop_out_requested.connect(self._pop_out_left_panel)
|
282
|
+
self.right_panel.pop_out_requested.connect(self._pop_out_right_panel)
|
283
|
+
|
284
|
+
# Mouse events (will be implemented in a separate handler)
|
285
|
+
self._setup_mouse_events()
|
286
|
+
|
287
|
+
def _setup_shortcuts(self):
|
288
|
+
"""Setup keyboard shortcuts based on hotkey manager."""
|
289
|
+
self.shortcuts = [] # Keep track of shortcuts for updating
|
290
|
+
self._update_shortcuts()
|
291
|
+
|
292
|
+
def _update_shortcuts(self):
|
293
|
+
"""Update shortcuts based on current hotkey configuration."""
|
294
|
+
# Clear existing shortcuts
|
295
|
+
for shortcut in self.shortcuts:
|
296
|
+
shortcut.setParent(None)
|
297
|
+
self.shortcuts.clear()
|
298
|
+
|
299
|
+
# Map action names to callbacks
|
300
|
+
action_callbacks = {
|
301
|
+
"load_next_image": self._load_next_image,
|
302
|
+
"load_previous_image": self._load_previous_image,
|
303
|
+
"sam_mode": self.set_sam_mode,
|
304
|
+
"polygon_mode": self.set_polygon_mode,
|
305
|
+
"selection_mode": self.toggle_selection_mode,
|
306
|
+
"pan_mode": self.toggle_pan_mode,
|
307
|
+
"edit_mode": self.toggle_edit_mode,
|
308
|
+
"clear_points": self.clear_all_points,
|
309
|
+
"escape": self._handle_escape_press,
|
310
|
+
"delete_segments": self._delete_selected_segments,
|
311
|
+
"delete_segments_alt": self._delete_selected_segments,
|
312
|
+
"merge_segments": self._handle_merge_press,
|
313
|
+
"undo": self._undo_last_action,
|
314
|
+
"redo": self._redo_last_action,
|
315
|
+
"select_all": lambda: self.right_panel.select_all_segments(),
|
316
|
+
"save_segment": self._handle_space_press,
|
317
|
+
"save_output": self._handle_enter_press,
|
318
|
+
"save_output_alt": self._handle_enter_press,
|
319
|
+
"fit_view": self.viewer.fitInView,
|
320
|
+
"zoom_in": self._handle_zoom_in,
|
321
|
+
"zoom_out": self._handle_zoom_out,
|
322
|
+
"pan_up": lambda: self._handle_pan_key("up"),
|
323
|
+
"pan_down": lambda: self._handle_pan_key("down"),
|
324
|
+
"pan_left": lambda: self._handle_pan_key("left"),
|
325
|
+
"pan_right": lambda: self._handle_pan_key("right"),
|
326
|
+
}
|
327
|
+
|
328
|
+
# Create shortcuts for each action
|
329
|
+
for action_name, callback in action_callbacks.items():
|
330
|
+
primary_key, secondary_key = self.hotkey_manager.get_key_for_action(
|
331
|
+
action_name
|
332
|
+
)
|
333
|
+
|
334
|
+
# Create primary shortcut
|
335
|
+
if primary_key:
|
336
|
+
shortcut = QShortcut(QKeySequence(primary_key), self, callback)
|
337
|
+
self.shortcuts.append(shortcut)
|
338
|
+
|
339
|
+
# Create secondary shortcut
|
340
|
+
if secondary_key:
|
341
|
+
shortcut = QShortcut(QKeySequence(secondary_key), self, callback)
|
342
|
+
self.shortcuts.append(shortcut)
|
343
|
+
|
344
|
+
def _load_settings(self):
|
345
|
+
"""Load and apply settings."""
|
346
|
+
self.control_panel.set_settings(self.settings.__dict__)
|
347
|
+
self.control_panel.set_annotation_size(
|
348
|
+
int(self.settings.annotation_size_multiplier * 10)
|
349
|
+
)
|
350
|
+
self.control_panel.set_join_threshold(self.settings.polygon_join_threshold)
|
351
|
+
# Set initial mode based on model availability
|
352
|
+
if self.model_manager.is_model_available():
|
353
|
+
self.set_sam_mode()
|
354
|
+
else:
|
355
|
+
self.set_polygon_mode()
|
356
|
+
|
357
|
+
def _setup_mouse_events(self):
|
358
|
+
"""Setup mouse event handling."""
|
359
|
+
self._original_mouse_press = self.viewer.scene().mousePressEvent
|
360
|
+
self._original_mouse_move = self.viewer.scene().mouseMoveEvent
|
361
|
+
self._original_mouse_release = self.viewer.scene().mouseReleaseEvent
|
362
|
+
|
363
|
+
self.viewer.scene().mousePressEvent = self._scene_mouse_press
|
364
|
+
self.viewer.scene().mouseMoveEvent = self._scene_mouse_move
|
365
|
+
self.viewer.scene().mouseReleaseEvent = self._scene_mouse_release
|
366
|
+
|
367
|
+
# Mode management methods
|
368
|
+
def set_sam_mode(self):
|
369
|
+
"""Set SAM points mode."""
|
370
|
+
if not self.model_manager.is_model_available():
|
371
|
+
print("Cannot enter SAM mode: No model available")
|
372
|
+
return
|
373
|
+
self._set_mode("sam_points")
|
374
|
+
|
375
|
+
def set_polygon_mode(self):
|
376
|
+
"""Set polygon drawing mode."""
|
377
|
+
self._set_mode("polygon")
|
378
|
+
|
379
|
+
def toggle_selection_mode(self):
|
380
|
+
"""Toggle selection mode."""
|
381
|
+
self._toggle_mode("selection")
|
382
|
+
|
383
|
+
def toggle_pan_mode(self):
|
384
|
+
"""Toggle pan mode."""
|
385
|
+
self._toggle_mode("pan")
|
386
|
+
|
387
|
+
def toggle_edit_mode(self):
|
388
|
+
"""Toggle edit mode."""
|
389
|
+
self._toggle_mode("edit")
|
390
|
+
|
391
|
+
def _set_mode(self, mode_name, is_toggle=False):
|
392
|
+
"""Set the current mode."""
|
393
|
+
if not is_toggle and self.mode not in ["selection", "edit"]:
|
394
|
+
self.previous_mode = self.mode
|
395
|
+
|
396
|
+
self.mode = mode_name
|
397
|
+
self.control_panel.set_mode_text(mode_name)
|
398
|
+
self.clear_all_points()
|
399
|
+
|
400
|
+
# Set cursor and drag mode based on mode
|
401
|
+
cursor_map = {
|
402
|
+
"sam_points": Qt.CursorShape.CrossCursor,
|
403
|
+
"polygon": Qt.CursorShape.CrossCursor,
|
404
|
+
"selection": Qt.CursorShape.ArrowCursor,
|
405
|
+
"edit": Qt.CursorShape.SizeAllCursor,
|
406
|
+
"pan": Qt.CursorShape.OpenHandCursor,
|
407
|
+
}
|
408
|
+
self.viewer.set_cursor(cursor_map.get(self.mode, Qt.CursorShape.ArrowCursor))
|
409
|
+
|
410
|
+
drag_mode = (
|
411
|
+
self.viewer.DragMode.ScrollHandDrag
|
412
|
+
if self.mode == "pan"
|
413
|
+
else self.viewer.DragMode.NoDrag
|
414
|
+
)
|
415
|
+
self.viewer.setDragMode(drag_mode)
|
416
|
+
|
417
|
+
# Update highlights and handles based on the new mode
|
418
|
+
self._highlight_selected_segments()
|
419
|
+
if mode_name == "edit":
|
420
|
+
self._display_edit_handles()
|
421
|
+
else:
|
422
|
+
self._clear_edit_handles()
|
423
|
+
|
424
|
+
def _toggle_mode(self, new_mode):
|
425
|
+
"""Toggle between modes."""
|
426
|
+
if self.mode == new_mode:
|
427
|
+
self._set_mode(self.previous_mode, is_toggle=True)
|
428
|
+
else:
|
429
|
+
if self.mode not in ["selection", "edit"]:
|
430
|
+
self.previous_mode = self.mode
|
431
|
+
self._set_mode(new_mode, is_toggle=True)
|
432
|
+
|
433
|
+
# Model management methods
|
434
|
+
def _browse_models_folder(self):
|
435
|
+
"""Browse for models folder."""
|
436
|
+
folder_path = QFileDialog.getExistingDirectory(self, "Select Models Folder")
|
437
|
+
if folder_path:
|
438
|
+
self.model_manager.set_models_folder(folder_path)
|
439
|
+
models = self.model_manager.get_available_models(folder_path)
|
440
|
+
self.control_panel.populate_models(models)
|
441
|
+
self.viewer.setFocus()
|
442
|
+
|
443
|
+
def _refresh_models_list(self):
|
444
|
+
"""Refresh the models list."""
|
445
|
+
folder = self.model_manager.get_models_folder()
|
446
|
+
if folder and os.path.exists(folder):
|
447
|
+
models = self.model_manager.get_available_models(folder)
|
448
|
+
self.control_panel.populate_models(models)
|
449
|
+
self._show_success_notification("Models list refreshed.")
|
450
|
+
else:
|
451
|
+
self._show_warning_notification("No models folder selected.")
|
452
|
+
|
453
|
+
def _load_selected_model(self, model_text):
|
454
|
+
"""Load the selected model."""
|
455
|
+
if not model_text or model_text == "Default (vit_h)":
|
456
|
+
self.control_panel.set_current_model("Current: Default SAM Model")
|
457
|
+
return
|
458
|
+
|
459
|
+
model_path = self.control_panel.model_widget.get_selected_model_path()
|
460
|
+
if not model_path or not os.path.exists(model_path):
|
461
|
+
self._show_error_notification("Selected model file not found.")
|
462
|
+
return
|
463
|
+
|
464
|
+
self.control_panel.set_current_model("Loading model...")
|
465
|
+
QApplication.processEvents()
|
466
|
+
|
467
|
+
try:
|
468
|
+
success = self.model_manager.load_custom_model(model_path)
|
469
|
+
if success:
|
470
|
+
# Re-enable SAM functionality if model loaded successfully
|
471
|
+
self._enable_sam_functionality(True)
|
472
|
+
if self.model_manager.sam_model:
|
473
|
+
device_text = str(self.model_manager.sam_model.device).upper()
|
474
|
+
self.status_bar.set_permanent_message(f"Device: {device_text}")
|
475
|
+
else:
|
476
|
+
self.control_panel.set_current_model("Current: Default SAM Model")
|
477
|
+
self._show_error_notification(
|
478
|
+
"Failed to load selected model. Using default."
|
479
|
+
)
|
480
|
+
self.control_panel.model_widget.reset_to_default()
|
481
|
+
self._enable_sam_functionality(False)
|
482
|
+
except Exception as e:
|
483
|
+
self.control_panel.set_current_model("Current: Default SAM Model")
|
484
|
+
self._show_error_notification(f"Error loading model: {str(e)}")
|
485
|
+
self.control_panel.model_widget.reset_to_default()
|
486
|
+
self._enable_sam_functionality(False)
|
487
|
+
|
488
|
+
# Adjustment methods
|
489
|
+
def _set_annotation_size(self, value):
|
490
|
+
"""Set annotation size."""
|
491
|
+
multiplier = value / 10.0
|
492
|
+
self.point_radius = self.settings.point_radius * multiplier
|
493
|
+
self.line_thickness = self.settings.line_thickness * multiplier
|
494
|
+
self.settings.annotation_size_multiplier = multiplier
|
495
|
+
# Update display (implementation would go here)
|
496
|
+
|
497
|
+
def _set_pan_speed(self, value):
|
498
|
+
"""Set pan speed."""
|
499
|
+
self.pan_multiplier = value / 10.0
|
500
|
+
self.settings.pan_multiplier = self.pan_multiplier
|
501
|
+
|
502
|
+
def _set_join_threshold(self, value):
|
503
|
+
"""Set polygon join threshold."""
|
504
|
+
self.polygon_join_threshold = value
|
505
|
+
self.settings.polygon_join_threshold = value
|
506
|
+
|
507
|
+
# File management methods
|
508
|
+
def _open_folder_dialog(self):
|
509
|
+
"""Open folder dialog for images."""
|
510
|
+
folder_path = QFileDialog.getExistingDirectory(self, "Select Image Folder")
|
511
|
+
if folder_path:
|
512
|
+
self.right_panel.set_folder(folder_path, self.file_model)
|
513
|
+
self.viewer.setFocus()
|
514
|
+
|
515
|
+
def _load_selected_image(self, index):
|
516
|
+
"""Load the selected image."""
|
517
|
+
if not index.isValid() or not self.file_model.isDir(index.parent()):
|
518
|
+
return
|
519
|
+
|
520
|
+
self.current_file_index = index
|
521
|
+
path = self.file_model.filePath(index)
|
522
|
+
|
523
|
+
if os.path.isfile(path) and self.file_manager.is_image_file(path):
|
524
|
+
self.current_image_path = path
|
525
|
+
pixmap = QPixmap(self.current_image_path)
|
526
|
+
if not pixmap.isNull():
|
527
|
+
self._reset_state()
|
528
|
+
self.viewer.set_photo(pixmap)
|
529
|
+
if self.model_manager.is_model_available():
|
530
|
+
self.model_manager.sam_model.set_image(self.current_image_path)
|
531
|
+
self.file_manager.load_class_aliases(self.current_image_path)
|
532
|
+
self.file_manager.load_existing_mask(self.current_image_path)
|
533
|
+
self.right_panel.file_tree.setCurrentIndex(index)
|
534
|
+
self._update_all_lists()
|
535
|
+
self.viewer.setFocus()
|
536
|
+
|
537
|
+
def _load_next_image(self):
|
538
|
+
"""Load next image in the file list, with auto-save if enabled."""
|
539
|
+
if not self.current_file_index.isValid():
|
540
|
+
return
|
541
|
+
# Auto-save if enabled
|
542
|
+
if self.control_panel.get_settings().get("auto_save", True):
|
543
|
+
self._save_output_to_npz()
|
544
|
+
parent = self.current_file_index.parent()
|
545
|
+
row = self.current_file_index.row()
|
546
|
+
# Find next valid image file
|
547
|
+
for next_row in range(row + 1, self.file_model.rowCount(parent)):
|
548
|
+
next_index = self.file_model.index(next_row, 0, parent)
|
549
|
+
path = self.file_model.filePath(next_index)
|
550
|
+
if os.path.isfile(path) and self.file_manager.is_image_file(path):
|
551
|
+
self._load_selected_image(next_index)
|
552
|
+
return
|
553
|
+
|
554
|
+
def _load_previous_image(self):
|
555
|
+
"""Load previous image in the file list, with auto-save if enabled."""
|
556
|
+
if not self.current_file_index.isValid():
|
557
|
+
return
|
558
|
+
# Auto-save if enabled
|
559
|
+
if self.control_panel.get_settings().get("auto_save", True):
|
560
|
+
self._save_output_to_npz()
|
561
|
+
parent = self.current_file_index.parent()
|
562
|
+
row = self.current_file_index.row()
|
563
|
+
# Find previous valid image file
|
564
|
+
for prev_row in range(row - 1, -1, -1):
|
565
|
+
prev_index = self.file_model.index(prev_row, 0, parent)
|
566
|
+
path = self.file_model.filePath(prev_index)
|
567
|
+
if os.path.isfile(path) and self.file_manager.is_image_file(path):
|
568
|
+
self._load_selected_image(prev_index)
|
569
|
+
return
|
570
|
+
|
571
|
+
# Segment management methods
|
572
|
+
def _assign_selected_to_class(self):
|
573
|
+
"""Assign selected segments to class."""
|
574
|
+
selected_indices = self.right_panel.get_selected_segment_indices()
|
575
|
+
self.segment_manager.assign_segments_to_class(selected_indices)
|
576
|
+
self._update_all_lists()
|
577
|
+
|
578
|
+
def _delete_selected_segments(self):
|
579
|
+
"""Delete selected segments and remove any highlight overlays."""
|
580
|
+
# Remove highlight overlays before deleting segments
|
581
|
+
if hasattr(self, "highlight_items"):
|
582
|
+
for item in self.highlight_items:
|
583
|
+
self.viewer.scene().removeItem(item)
|
584
|
+
self.highlight_items = []
|
585
|
+
selected_indices = self.right_panel.get_selected_segment_indices()
|
586
|
+
self.segment_manager.delete_segments(selected_indices)
|
587
|
+
self._update_all_lists()
|
588
|
+
|
589
|
+
def _highlight_selected_segments(self):
|
590
|
+
"""Highlight selected segments. In edit mode, use a brighter hover-like effect."""
|
591
|
+
# Remove previous highlight overlays
|
592
|
+
if hasattr(self, "highlight_items"):
|
593
|
+
for item in self.highlight_items:
|
594
|
+
if item.scene():
|
595
|
+
self.viewer.scene().removeItem(item)
|
596
|
+
self.highlight_items = []
|
597
|
+
|
598
|
+
selected_indices = self.right_panel.get_selected_segment_indices()
|
599
|
+
if not selected_indices:
|
600
|
+
return
|
601
|
+
|
602
|
+
for i in selected_indices:
|
603
|
+
seg = self.segment_manager.segments[i]
|
604
|
+
base_color = self._get_color_for_class(seg.get("class_id"))
|
605
|
+
|
606
|
+
if self.mode == "edit":
|
607
|
+
# Use a brighter, hover-like highlight in edit mode
|
608
|
+
highlight_brush = QBrush(
|
609
|
+
QColor(base_color.red(), base_color.green(), base_color.blue(), 170)
|
610
|
+
)
|
611
|
+
else:
|
612
|
+
# Use the standard yellow overlay for selection
|
613
|
+
highlight_brush = QBrush(QColor(255, 255, 0, 180))
|
614
|
+
|
615
|
+
if seg["type"] == "Polygon" and seg.get("vertices"):
|
616
|
+
poly_item = QGraphicsPolygonItem(QPolygonF(seg["vertices"]))
|
617
|
+
poly_item.setBrush(highlight_brush)
|
618
|
+
poly_item.setPen(QPen(Qt.GlobalColor.transparent))
|
619
|
+
poly_item.setZValue(99)
|
620
|
+
self.viewer.scene().addItem(poly_item)
|
621
|
+
self.highlight_items.append(poly_item)
|
622
|
+
elif seg.get("mask") is not None:
|
623
|
+
# For non-polygon types, we still use the mask-to-pixmap approach.
|
624
|
+
# If in edit mode, we could consider skipping non-polygons.
|
625
|
+
if self.mode != "edit":
|
626
|
+
mask = seg.get("mask")
|
627
|
+
pixmap = mask_to_pixmap(mask, (255, 255, 0), alpha=180)
|
628
|
+
highlight_item = self.viewer.scene().addPixmap(pixmap)
|
629
|
+
highlight_item.setZValue(100)
|
630
|
+
self.highlight_items.append(highlight_item)
|
631
|
+
|
632
|
+
def _handle_alias_change(self, class_id, alias):
|
633
|
+
"""Handle class alias change."""
|
634
|
+
if self._updating_lists:
|
635
|
+
return # Prevent recursion
|
636
|
+
self.segment_manager.set_class_alias(class_id, alias)
|
637
|
+
self._update_all_lists()
|
638
|
+
|
639
|
+
def _reassign_class_ids(self):
|
640
|
+
"""Reassign class IDs."""
|
641
|
+
new_order = self.right_panel.get_class_order()
|
642
|
+
self.segment_manager.reassign_class_ids(new_order)
|
643
|
+
self._update_all_lists()
|
644
|
+
|
645
|
+
def _update_segment_table(self):
|
646
|
+
"""Update segment table."""
|
647
|
+
table = self.right_panel.segment_table
|
648
|
+
table.blockSignals(True)
|
649
|
+
selected_indices = self.right_panel.get_selected_segment_indices()
|
650
|
+
table.clearContents()
|
651
|
+
table.setRowCount(0)
|
652
|
+
|
653
|
+
# Get current filter
|
654
|
+
filter_text = self.right_panel.class_filter_combo.currentText()
|
655
|
+
show_all = filter_text == "All Classes"
|
656
|
+
filter_class_id = -1
|
657
|
+
if not show_all:
|
658
|
+
try:
|
659
|
+
# Parse format like "Alias: ID" or "Class ID"
|
660
|
+
if ":" in filter_text:
|
661
|
+
filter_class_id = int(filter_text.split(":")[-1].strip())
|
662
|
+
else:
|
663
|
+
filter_class_id = int(filter_text.split()[-1])
|
664
|
+
except (ValueError, IndexError):
|
665
|
+
show_all = True # If parsing fails, show all
|
666
|
+
|
667
|
+
# Filter segments based on class filter
|
668
|
+
display_segments = []
|
669
|
+
for i, seg in enumerate(self.segment_manager.segments):
|
670
|
+
seg_class_id = seg.get("class_id")
|
671
|
+
should_include = show_all or seg_class_id == filter_class_id
|
672
|
+
if should_include:
|
673
|
+
display_segments.append((i, seg))
|
674
|
+
|
675
|
+
table.setRowCount(len(display_segments))
|
676
|
+
|
677
|
+
# Populate table rows
|
678
|
+
for row, (original_index, seg) in enumerate(display_segments):
|
679
|
+
class_id = seg.get("class_id")
|
680
|
+
color = self._get_color_for_class(class_id)
|
681
|
+
class_id_str = str(class_id) if class_id is not None else "N/A"
|
682
|
+
|
683
|
+
alias_str = "N/A"
|
684
|
+
if class_id is not None:
|
685
|
+
alias_str = self.segment_manager.get_class_alias(class_id)
|
686
|
+
|
687
|
+
# Create table items (1-based segment ID for display)
|
688
|
+
index_item = NumericTableWidgetItem(str(original_index + 1))
|
689
|
+
class_item = NumericTableWidgetItem(class_id_str)
|
690
|
+
alias_item = QTableWidgetItem(alias_str)
|
691
|
+
|
692
|
+
# Set items as non-editable
|
693
|
+
index_item.setFlags(index_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
694
|
+
class_item.setFlags(class_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
695
|
+
alias_item.setFlags(alias_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
696
|
+
|
697
|
+
# Store original index for selection tracking
|
698
|
+
index_item.setData(Qt.ItemDataRole.UserRole, original_index)
|
699
|
+
|
700
|
+
# Set items in table
|
701
|
+
table.setItem(row, 0, index_item)
|
702
|
+
table.setItem(row, 1, class_item)
|
703
|
+
table.setItem(row, 2, alias_item)
|
704
|
+
|
705
|
+
# Set background color based on class
|
706
|
+
for col in range(table.columnCount()):
|
707
|
+
if table.item(row, col):
|
708
|
+
table.item(row, col).setBackground(QBrush(color))
|
709
|
+
|
710
|
+
# Restore selection
|
711
|
+
table.setSortingEnabled(False)
|
712
|
+
for row in range(table.rowCount()):
|
713
|
+
item = table.item(row, 0)
|
714
|
+
if item and item.data(Qt.ItemDataRole.UserRole) in selected_indices:
|
715
|
+
table.selectRow(row)
|
716
|
+
table.setSortingEnabled(True)
|
717
|
+
|
718
|
+
table.blockSignals(False)
|
719
|
+
self.viewer.setFocus()
|
720
|
+
|
721
|
+
# Update active class display
|
722
|
+
active_class = self.segment_manager.get_active_class()
|
723
|
+
self.right_panel.update_active_class_display(active_class)
|
724
|
+
|
725
|
+
def _update_all_lists(self):
|
726
|
+
"""Update all UI lists."""
|
727
|
+
if self._updating_lists:
|
728
|
+
return # Prevent recursion
|
729
|
+
|
730
|
+
self._updating_lists = True
|
731
|
+
try:
|
732
|
+
self._update_class_list()
|
733
|
+
self._update_segment_table()
|
734
|
+
self._update_class_filter()
|
735
|
+
self._display_all_segments()
|
736
|
+
if self.mode == "edit":
|
737
|
+
self._display_edit_handles()
|
738
|
+
else:
|
739
|
+
self._clear_edit_handles()
|
740
|
+
finally:
|
741
|
+
self._updating_lists = False
|
742
|
+
|
743
|
+
def _update_class_list(self):
|
744
|
+
"""Update the class list in the right panel."""
|
745
|
+
class_table = self.right_panel.class_table
|
746
|
+
class_table.blockSignals(True)
|
747
|
+
|
748
|
+
# Get unique class IDs
|
749
|
+
unique_class_ids = self.segment_manager.get_unique_class_ids()
|
750
|
+
|
751
|
+
class_table.clearContents()
|
752
|
+
class_table.setRowCount(len(unique_class_ids))
|
753
|
+
|
754
|
+
for row, cid in enumerate(unique_class_ids):
|
755
|
+
alias_item = QTableWidgetItem(self.segment_manager.get_class_alias(cid))
|
756
|
+
id_item = QTableWidgetItem(str(cid))
|
757
|
+
id_item.setFlags(id_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
758
|
+
|
759
|
+
color = self._get_color_for_class(cid)
|
760
|
+
alias_item.setBackground(QBrush(color))
|
761
|
+
id_item.setBackground(QBrush(color))
|
762
|
+
|
763
|
+
class_table.setItem(row, 0, alias_item)
|
764
|
+
class_table.setItem(row, 1, id_item)
|
765
|
+
|
766
|
+
# Update active class display BEFORE re-enabling signals
|
767
|
+
active_class = self.segment_manager.get_active_class()
|
768
|
+
self.right_panel.update_active_class_display(active_class)
|
769
|
+
|
770
|
+
class_table.blockSignals(False)
|
771
|
+
|
772
|
+
def _update_class_filter(self):
|
773
|
+
"""Update the class filter combo box."""
|
774
|
+
combo = self.right_panel.class_filter_combo
|
775
|
+
current_text = combo.currentText()
|
776
|
+
|
777
|
+
combo.blockSignals(True)
|
778
|
+
combo.clear()
|
779
|
+
combo.addItem("All Classes")
|
780
|
+
|
781
|
+
# Add class options
|
782
|
+
unique_class_ids = self.segment_manager.get_unique_class_ids()
|
783
|
+
for class_id in unique_class_ids:
|
784
|
+
alias = self.segment_manager.get_class_alias(class_id)
|
785
|
+
display_text = f"{alias}: {class_id}" if alias else f"Class {class_id}"
|
786
|
+
combo.addItem(display_text)
|
787
|
+
|
788
|
+
# Restore selection if possible
|
789
|
+
index = combo.findText(current_text)
|
790
|
+
if index >= 0:
|
791
|
+
combo.setCurrentIndex(index)
|
792
|
+
else:
|
793
|
+
combo.setCurrentIndex(0)
|
794
|
+
|
795
|
+
combo.blockSignals(False)
|
796
|
+
|
797
|
+
def _display_all_segments(self):
|
798
|
+
"""Display all segments on the viewer."""
|
799
|
+
# Clear existing segment items
|
800
|
+
for _i, items in self.segment_items.items():
|
801
|
+
for item in items:
|
802
|
+
if item.scene():
|
803
|
+
self.viewer.scene().removeItem(item)
|
804
|
+
self.segment_items.clear()
|
805
|
+
self._clear_edit_handles()
|
806
|
+
|
807
|
+
# Display segments from segment manager
|
808
|
+
for i, segment in enumerate(self.segment_manager.segments):
|
809
|
+
self.segment_items[i] = []
|
810
|
+
class_id = segment.get("class_id")
|
811
|
+
base_color = self._get_color_for_class(class_id)
|
812
|
+
|
813
|
+
if segment["type"] == "Polygon" and segment.get("vertices"):
|
814
|
+
poly_item = HoverablePolygonItem(QPolygonF(segment["vertices"]))
|
815
|
+
default_brush = QBrush(
|
816
|
+
QColor(base_color.red(), base_color.green(), base_color.blue(), 70)
|
817
|
+
)
|
818
|
+
hover_brush = QBrush(
|
819
|
+
QColor(base_color.red(), base_color.green(), base_color.blue(), 170)
|
820
|
+
)
|
821
|
+
poly_item.set_brushes(default_brush, hover_brush)
|
822
|
+
poly_item.setPen(QPen(Qt.GlobalColor.transparent))
|
823
|
+
self.viewer.scene().addItem(poly_item)
|
824
|
+
self.segment_items[i].append(poly_item)
|
825
|
+
elif segment.get("mask") is not None:
|
826
|
+
default_pixmap = mask_to_pixmap(
|
827
|
+
segment["mask"], base_color.getRgb()[:3], alpha=70
|
828
|
+
)
|
829
|
+
hover_pixmap = mask_to_pixmap(
|
830
|
+
segment["mask"], base_color.getRgb()[:3], alpha=170
|
831
|
+
)
|
832
|
+
pixmap_item = HoverablePixmapItem()
|
833
|
+
pixmap_item.set_pixmaps(default_pixmap, hover_pixmap)
|
834
|
+
self.viewer.scene().addItem(pixmap_item)
|
835
|
+
pixmap_item.setZValue(i + 1)
|
836
|
+
self.segment_items[i].append(pixmap_item)
|
837
|
+
|
838
|
+
# Event handlers
|
839
|
+
def _handle_escape_press(self):
|
840
|
+
"""Handle escape key press."""
|
841
|
+
self.right_panel.clear_selections()
|
842
|
+
self.clear_all_points()
|
843
|
+
self.viewer.setFocus()
|
844
|
+
|
845
|
+
def _handle_space_press(self):
|
846
|
+
"""Handle space key press."""
|
847
|
+
if self.mode == "polygon" and self.polygon_points:
|
848
|
+
self._finalize_polygon()
|
849
|
+
else:
|
850
|
+
self._save_current_segment()
|
851
|
+
|
852
|
+
def _handle_enter_press(self):
|
853
|
+
"""Handle enter key press."""
|
854
|
+
if self.mode == "polygon" and self.polygon_points:
|
855
|
+
self._finalize_polygon()
|
856
|
+
else:
|
857
|
+
self._save_output_to_npz()
|
858
|
+
|
859
|
+
def _save_current_segment(self):
|
860
|
+
"""Save current SAM segment."""
|
861
|
+
if (
|
862
|
+
self.mode != "sam_points"
|
863
|
+
or not hasattr(self, "preview_mask_item")
|
864
|
+
or not self.preview_mask_item
|
865
|
+
or not self.model_manager.is_model_available()
|
866
|
+
):
|
867
|
+
return
|
868
|
+
|
869
|
+
mask = self.model_manager.sam_model.predict(
|
870
|
+
self.positive_points, self.negative_points
|
871
|
+
)
|
872
|
+
if mask is not None:
|
873
|
+
new_segment = {
|
874
|
+
"mask": mask,
|
875
|
+
"type": "SAM",
|
876
|
+
"vertices": None,
|
877
|
+
}
|
878
|
+
self.segment_manager.add_segment(new_segment)
|
879
|
+
# Record the action for undo
|
880
|
+
self.action_history.append(
|
881
|
+
{
|
882
|
+
"type": "add_segment",
|
883
|
+
"segment_index": len(self.segment_manager.segments) - 1,
|
884
|
+
}
|
885
|
+
)
|
886
|
+
# Clear redo history when a new action is performed
|
887
|
+
self.redo_history.clear()
|
888
|
+
self.clear_all_points()
|
889
|
+
self._update_all_lists()
|
890
|
+
|
891
|
+
def _finalize_polygon(self):
|
892
|
+
"""Finalize polygon drawing."""
|
893
|
+
if len(self.polygon_points) < 3:
|
894
|
+
return
|
895
|
+
|
896
|
+
new_segment = {
|
897
|
+
"vertices": list(self.polygon_points),
|
898
|
+
"type": "Polygon",
|
899
|
+
"mask": None,
|
900
|
+
}
|
901
|
+
self.segment_manager.add_segment(new_segment)
|
902
|
+
# Record the action for undo
|
903
|
+
self.action_history.append(
|
904
|
+
{
|
905
|
+
"type": "add_segment",
|
906
|
+
"segment_index": len(self.segment_manager.segments) - 1,
|
907
|
+
}
|
908
|
+
)
|
909
|
+
# Clear redo history when a new action is performed
|
910
|
+
self.redo_history.clear()
|
911
|
+
|
912
|
+
self.polygon_points.clear()
|
913
|
+
self.clear_all_points()
|
914
|
+
self._update_all_lists()
|
915
|
+
|
916
|
+
def _save_output_to_npz(self):
|
917
|
+
"""Save output to NPZ and TXT files as enabled, and update file list tickboxes/highlight. If no segments, delete associated files."""
|
918
|
+
if not self.current_image_path:
|
919
|
+
self._show_warning_notification("No image loaded.")
|
920
|
+
return
|
921
|
+
|
922
|
+
# If no segments, delete associated files
|
923
|
+
if not self.segment_manager.segments:
|
924
|
+
base, _ = os.path.splitext(self.current_image_path)
|
925
|
+
deleted_files = []
|
926
|
+
for ext in [".npz", ".txt", ".json"]:
|
927
|
+
file_path = base + ext
|
928
|
+
if os.path.exists(file_path):
|
929
|
+
try:
|
930
|
+
os.remove(file_path)
|
931
|
+
deleted_files.append(file_path)
|
932
|
+
self.file_model.update_cache_for_path(file_path)
|
933
|
+
except Exception as e:
|
934
|
+
self._show_error_notification(
|
935
|
+
f"Error deleting {file_path}: {e}"
|
936
|
+
)
|
937
|
+
if deleted_files:
|
938
|
+
self._show_notification(
|
939
|
+
f"Deleted: {', '.join(os.path.basename(f) for f in deleted_files)}"
|
940
|
+
)
|
941
|
+
else:
|
942
|
+
self._show_warning_notification("No segments to save.")
|
943
|
+
return
|
944
|
+
|
945
|
+
try:
|
946
|
+
settings = self.control_panel.get_settings()
|
947
|
+
npz_path = None
|
948
|
+
txt_path = None
|
949
|
+
if settings.get("save_npz", True):
|
950
|
+
h, w = (
|
951
|
+
self.viewer._pixmap_item.pixmap().height(),
|
952
|
+
self.viewer._pixmap_item.pixmap().width(),
|
953
|
+
)
|
954
|
+
class_order = self.segment_manager.get_unique_class_ids()
|
955
|
+
if class_order:
|
956
|
+
npz_path = self.file_manager.save_npz(
|
957
|
+
self.current_image_path, (h, w), class_order
|
958
|
+
)
|
959
|
+
self._show_success_notification(
|
960
|
+
f"Saved: {os.path.basename(npz_path)}"
|
961
|
+
)
|
962
|
+
else:
|
963
|
+
self._show_warning_notification("No classes defined for saving.")
|
964
|
+
if settings.get("save_txt", True):
|
965
|
+
h, w = (
|
966
|
+
self.viewer._pixmap_item.pixmap().height(),
|
967
|
+
self.viewer._pixmap_item.pixmap().width(),
|
968
|
+
)
|
969
|
+
class_order = self.segment_manager.get_unique_class_ids()
|
970
|
+
if settings.get("yolo_use_alias", True):
|
971
|
+
class_labels = [
|
972
|
+
self.segment_manager.get_class_alias(cid) for cid in class_order
|
973
|
+
]
|
974
|
+
else:
|
975
|
+
class_labels = [str(cid) for cid in class_order]
|
976
|
+
if class_order:
|
977
|
+
txt_path = self.file_manager.save_yolo_txt(
|
978
|
+
self.current_image_path, (h, w), class_order, class_labels
|
979
|
+
)
|
980
|
+
# Efficiently update file list tickboxes and highlight
|
981
|
+
for path in [npz_path, txt_path]:
|
982
|
+
if path:
|
983
|
+
self.file_model.update_cache_for_path(path)
|
984
|
+
self.file_model.set_highlighted_path(path)
|
985
|
+
QTimer.singleShot(
|
986
|
+
1500,
|
987
|
+
lambda p=path: (
|
988
|
+
self.file_model.set_highlighted_path(None)
|
989
|
+
if self.file_model.highlighted_path == p
|
990
|
+
else None
|
991
|
+
),
|
992
|
+
)
|
993
|
+
except Exception as e:
|
994
|
+
self._show_error_notification(f"Error saving: {str(e)}")
|
995
|
+
|
996
|
+
def _handle_merge_press(self):
|
997
|
+
"""Handle merge key press."""
|
998
|
+
self._assign_selected_to_class()
|
999
|
+
self.right_panel.clear_selections()
|
1000
|
+
|
1001
|
+
def _undo_last_action(self):
|
1002
|
+
"""Undo the last action recorded in the history."""
|
1003
|
+
if not self.action_history:
|
1004
|
+
self._show_notification("Nothing to undo.")
|
1005
|
+
return
|
1006
|
+
|
1007
|
+
last_action = self.action_history.pop()
|
1008
|
+
action_type = last_action.get("type")
|
1009
|
+
|
1010
|
+
# Save to redo history before undoing
|
1011
|
+
self.redo_history.append(last_action)
|
1012
|
+
|
1013
|
+
if action_type == "add_segment":
|
1014
|
+
segment_index = last_action.get("segment_index")
|
1015
|
+
if segment_index is not None and 0 <= segment_index < len(
|
1016
|
+
self.segment_manager.segments
|
1017
|
+
):
|
1018
|
+
# Store the segment data for redo
|
1019
|
+
last_action["segment_data"] = self.segment_manager.segments[
|
1020
|
+
segment_index
|
1021
|
+
].copy()
|
1022
|
+
|
1023
|
+
# Remove the segment that was added
|
1024
|
+
self.segment_manager.delete_segments([segment_index])
|
1025
|
+
self.right_panel.clear_selections() # Clear selection to prevent phantom highlights
|
1026
|
+
self._update_all_lists()
|
1027
|
+
self._show_notification("Undid: Add Segment")
|
1028
|
+
elif action_type == "add_point":
|
1029
|
+
point_type = last_action.get("point_type")
|
1030
|
+
point_item = last_action.get("point_item")
|
1031
|
+
point_list = (
|
1032
|
+
self.positive_points
|
1033
|
+
if point_type == "positive"
|
1034
|
+
else self.negative_points
|
1035
|
+
)
|
1036
|
+
if point_list:
|
1037
|
+
point_list.pop()
|
1038
|
+
if point_item in self.point_items:
|
1039
|
+
self.point_items.remove(point_item)
|
1040
|
+
self.viewer.scene().removeItem(point_item)
|
1041
|
+
self._update_segmentation()
|
1042
|
+
self._show_notification("Undid: Add Point")
|
1043
|
+
elif action_type == "add_polygon_point":
|
1044
|
+
dot_item = last_action.get("dot_item")
|
1045
|
+
if self.polygon_points:
|
1046
|
+
self.polygon_points.pop()
|
1047
|
+
if dot_item in self.polygon_preview_items:
|
1048
|
+
self.polygon_preview_items.remove(dot_item)
|
1049
|
+
self.viewer.scene().removeItem(dot_item)
|
1050
|
+
self._draw_polygon_preview()
|
1051
|
+
self._show_notification("Undid: Add Polygon Point")
|
1052
|
+
elif action_type == "move_polygon":
|
1053
|
+
initial_vertices = last_action.get("initial_vertices")
|
1054
|
+
for i, vertices in initial_vertices.items():
|
1055
|
+
self.segment_manager.segments[i]["vertices"] = vertices
|
1056
|
+
self._update_polygon_item(i)
|
1057
|
+
self._display_edit_handles()
|
1058
|
+
self._highlight_selected_segments()
|
1059
|
+
self._show_notification("Undid: Move Polygon")
|
1060
|
+
elif action_type == "move_vertex":
|
1061
|
+
segment_index = last_action.get("segment_index")
|
1062
|
+
vertex_index = last_action.get("vertex_index")
|
1063
|
+
old_pos = last_action.get("old_pos")
|
1064
|
+
self.segment_manager.segments[segment_index]["vertices"][vertex_index] = (
|
1065
|
+
old_pos
|
1066
|
+
)
|
1067
|
+
self._update_polygon_item(segment_index)
|
1068
|
+
self._display_edit_handles()
|
1069
|
+
self._highlight_selected_segments()
|
1070
|
+
self._show_notification("Undid: Move Vertex")
|
1071
|
+
|
1072
|
+
# Add more undo logic for other action types here in the future
|
1073
|
+
else:
|
1074
|
+
self._show_warning_notification(
|
1075
|
+
f"Undo for action '{action_type}' not implemented."
|
1076
|
+
)
|
1077
|
+
# Remove from redo history if we couldn't undo it
|
1078
|
+
self.redo_history.pop()
|
1079
|
+
|
1080
|
+
def _redo_last_action(self):
|
1081
|
+
"""Redo the last undone action."""
|
1082
|
+
if not self.redo_history:
|
1083
|
+
self._show_notification("Nothing to redo.")
|
1084
|
+
return
|
1085
|
+
|
1086
|
+
last_action = self.redo_history.pop()
|
1087
|
+
action_type = last_action.get("type")
|
1088
|
+
|
1089
|
+
# Add back to action history for potential future undo
|
1090
|
+
self.action_history.append(last_action)
|
1091
|
+
|
1092
|
+
if action_type == "add_segment":
|
1093
|
+
# Restore the segment that was removed
|
1094
|
+
if "segment_data" in last_action:
|
1095
|
+
segment_data = last_action["segment_data"]
|
1096
|
+
self.segment_manager.add_segment(segment_data)
|
1097
|
+
self._update_all_lists()
|
1098
|
+
self._show_notification("Redid: Add Segment")
|
1099
|
+
else:
|
1100
|
+
# If we don't have the segment data (shouldn't happen), we can't redo
|
1101
|
+
self._show_warning_notification("Cannot redo: Missing segment data")
|
1102
|
+
self.action_history.pop() # Remove from action history
|
1103
|
+
elif action_type == "add_point":
|
1104
|
+
point_type = last_action.get("point_type")
|
1105
|
+
point_coords = last_action.get("point_coords")
|
1106
|
+
if point_coords:
|
1107
|
+
pos = QPointF(point_coords[0], point_coords[1])
|
1108
|
+
self._add_point(pos, positive=(point_type == "positive"))
|
1109
|
+
self._update_segmentation()
|
1110
|
+
self._show_notification("Redid: Add Point")
|
1111
|
+
else:
|
1112
|
+
self._show_warning_notification(
|
1113
|
+
"Cannot redo: Missing point coordinates"
|
1114
|
+
)
|
1115
|
+
self.action_history.pop()
|
1116
|
+
elif action_type == "add_polygon_point":
|
1117
|
+
point_coords = last_action.get("point_coords")
|
1118
|
+
if point_coords:
|
1119
|
+
self._handle_polygon_click(point_coords)
|
1120
|
+
self._show_notification("Redid: Add Polygon Point")
|
1121
|
+
else:
|
1122
|
+
self._show_warning_notification(
|
1123
|
+
"Cannot redo: Missing polygon point coordinates"
|
1124
|
+
)
|
1125
|
+
self.action_history.pop()
|
1126
|
+
elif action_type == "move_polygon":
|
1127
|
+
final_vertices = last_action.get("final_vertices")
|
1128
|
+
if final_vertices:
|
1129
|
+
for i, vertices in final_vertices.items():
|
1130
|
+
if i < len(self.segment_manager.segments):
|
1131
|
+
self.segment_manager.segments[i]["vertices"] = [
|
1132
|
+
QPointF(v.x(), v.y()) for v in vertices
|
1133
|
+
]
|
1134
|
+
self._update_polygon_item(i)
|
1135
|
+
self._display_edit_handles()
|
1136
|
+
self._highlight_selected_segments()
|
1137
|
+
self._show_notification("Redid: Move Polygon")
|
1138
|
+
else:
|
1139
|
+
self._show_warning_notification("Cannot redo: Missing final vertices")
|
1140
|
+
self.action_history.pop()
|
1141
|
+
elif action_type == "move_vertex":
|
1142
|
+
segment_index = last_action.get("segment_index")
|
1143
|
+
vertex_index = last_action.get("vertex_index")
|
1144
|
+
new_pos = last_action.get("new_pos")
|
1145
|
+
if (
|
1146
|
+
segment_index is not None
|
1147
|
+
and vertex_index is not None
|
1148
|
+
and new_pos is not None
|
1149
|
+
):
|
1150
|
+
if segment_index < len(self.segment_manager.segments):
|
1151
|
+
self.segment_manager.segments[segment_index]["vertices"][
|
1152
|
+
vertex_index
|
1153
|
+
] = new_pos
|
1154
|
+
self._update_polygon_item(segment_index)
|
1155
|
+
self._display_edit_handles()
|
1156
|
+
self._highlight_selected_segments()
|
1157
|
+
self._show_notification("Redid: Move Vertex")
|
1158
|
+
else:
|
1159
|
+
self._show_warning_notification(
|
1160
|
+
"Cannot redo: Segment no longer exists"
|
1161
|
+
)
|
1162
|
+
self.action_history.pop()
|
1163
|
+
else:
|
1164
|
+
self._show_warning_notification("Cannot redo: Missing vertex data")
|
1165
|
+
self.action_history.pop()
|
1166
|
+
else:
|
1167
|
+
self._show_warning_notification(
|
1168
|
+
f"Redo for action '{action_type}' not implemented."
|
1169
|
+
)
|
1170
|
+
# Remove from action history if we couldn't redo it
|
1171
|
+
self.action_history.pop()
|
1172
|
+
|
1173
|
+
def clear_all_points(self):
|
1174
|
+
"""Clear all temporary points."""
|
1175
|
+
if hasattr(self, "rubber_band_line") and self.rubber_band_line:
|
1176
|
+
self.viewer.scene().removeItem(self.rubber_band_line)
|
1177
|
+
self.rubber_band_line = None
|
1178
|
+
|
1179
|
+
self.positive_points.clear()
|
1180
|
+
self.negative_points.clear()
|
1181
|
+
|
1182
|
+
for item in self.point_items:
|
1183
|
+
self.viewer.scene().removeItem(item)
|
1184
|
+
self.point_items.clear()
|
1185
|
+
|
1186
|
+
self.polygon_points.clear()
|
1187
|
+
for item in self.polygon_preview_items:
|
1188
|
+
self.viewer.scene().removeItem(item)
|
1189
|
+
self.polygon_preview_items.clear()
|
1190
|
+
|
1191
|
+
if hasattr(self, "preview_mask_item") and self.preview_mask_item:
|
1192
|
+
self.viewer.scene().removeItem(self.preview_mask_item)
|
1193
|
+
self.preview_mask_item = None
|
1194
|
+
|
1195
|
+
def _show_notification(self, message, duration=3000):
|
1196
|
+
"""Show notification message."""
|
1197
|
+
self.status_bar.show_message(message, duration)
|
1198
|
+
|
1199
|
+
def _show_error_notification(self, message, duration=8000):
|
1200
|
+
"""Show error notification message."""
|
1201
|
+
self.status_bar.show_error_message(message, duration)
|
1202
|
+
|
1203
|
+
def _show_success_notification(self, message, duration=3000):
|
1204
|
+
"""Show success notification message."""
|
1205
|
+
self.status_bar.show_success_message(message, duration)
|
1206
|
+
|
1207
|
+
def _show_warning_notification(self, message, duration=5000):
|
1208
|
+
"""Show warning notification message."""
|
1209
|
+
self.status_bar.show_warning_message(message, duration)
|
1210
|
+
|
1211
|
+
def _show_hotkey_dialog(self):
|
1212
|
+
"""Show the hotkey configuration dialog."""
|
1213
|
+
dialog = HotkeyDialog(self.hotkey_manager, self)
|
1214
|
+
dialog.exec()
|
1215
|
+
# Update shortcuts after dialog closes
|
1216
|
+
self._update_shortcuts()
|
1217
|
+
|
1218
|
+
def _handle_zoom_in(self):
|
1219
|
+
"""Handle zoom in."""
|
1220
|
+
current_val = self.control_panel.get_annotation_size()
|
1221
|
+
self.control_panel.set_annotation_size(min(current_val + 1, 50))
|
1222
|
+
|
1223
|
+
def _handle_zoom_out(self):
|
1224
|
+
"""Handle zoom out."""
|
1225
|
+
current_val = self.control_panel.get_annotation_size()
|
1226
|
+
self.control_panel.set_annotation_size(max(current_val - 1, 1))
|
1227
|
+
|
1228
|
+
def _handle_pan_key(self, direction):
|
1229
|
+
"""Handle WASD pan keys."""
|
1230
|
+
if not hasattr(self, "viewer"):
|
1231
|
+
return
|
1232
|
+
|
1233
|
+
amount = int(self.viewer.height() * 0.1 * self.pan_multiplier)
|
1234
|
+
|
1235
|
+
if direction == "up":
|
1236
|
+
self.viewer.verticalScrollBar().setValue(
|
1237
|
+
self.viewer.verticalScrollBar().value() - amount
|
1238
|
+
)
|
1239
|
+
elif direction == "down":
|
1240
|
+
self.viewer.verticalScrollBar().setValue(
|
1241
|
+
self.viewer.verticalScrollBar().value() + amount
|
1242
|
+
)
|
1243
|
+
elif direction == "left":
|
1244
|
+
amount = int(self.viewer.width() * 0.1 * self.pan_multiplier)
|
1245
|
+
self.viewer.horizontalScrollBar().setValue(
|
1246
|
+
self.viewer.horizontalScrollBar().value() - amount
|
1247
|
+
)
|
1248
|
+
elif direction == "right":
|
1249
|
+
amount = int(self.viewer.width() * 0.1 * self.pan_multiplier)
|
1250
|
+
self.viewer.horizontalScrollBar().setValue(
|
1251
|
+
self.viewer.horizontalScrollBar().value() + amount
|
1252
|
+
)
|
1253
|
+
|
1254
|
+
def closeEvent(self, event):
|
1255
|
+
"""Handle application close."""
|
1256
|
+
# Close any popped-out panels first
|
1257
|
+
if self.left_panel_popout is not None:
|
1258
|
+
self.left_panel_popout.close()
|
1259
|
+
if self.right_panel_popout is not None:
|
1260
|
+
self.right_panel_popout.close()
|
1261
|
+
|
1262
|
+
# Save settings
|
1263
|
+
self.settings.save_to_file(str(self.paths.settings_file))
|
1264
|
+
super().closeEvent(event)
|
1265
|
+
|
1266
|
+
def _reset_state(self):
|
1267
|
+
"""Reset application state."""
|
1268
|
+
self.clear_all_points()
|
1269
|
+
self.segment_manager.clear()
|
1270
|
+
self._update_all_lists()
|
1271
|
+
items_to_remove = [
|
1272
|
+
item
|
1273
|
+
for item in self.viewer.scene().items()
|
1274
|
+
if item is not self.viewer._pixmap_item
|
1275
|
+
]
|
1276
|
+
for item in items_to_remove:
|
1277
|
+
self.viewer.scene().removeItem(item)
|
1278
|
+
self.segment_items.clear()
|
1279
|
+
self.highlight_items.clear()
|
1280
|
+
self.action_history.clear()
|
1281
|
+
self.redo_history.clear()
|
1282
|
+
|
1283
|
+
def _scene_mouse_press(self, event):
|
1284
|
+
"""Handle mouse press events in the scene."""
|
1285
|
+
# Map scene coordinates to the view so items() works correctly.
|
1286
|
+
view_pos = self.viewer.mapFromScene(event.scenePos())
|
1287
|
+
items_at_pos = self.viewer.items(view_pos)
|
1288
|
+
is_handle_click = any(
|
1289
|
+
isinstance(item, EditableVertexItem) for item in items_at_pos
|
1290
|
+
)
|
1291
|
+
|
1292
|
+
# Allow vertex handles to process their own mouse events.
|
1293
|
+
if is_handle_click:
|
1294
|
+
self._original_mouse_press(event)
|
1295
|
+
return
|
1296
|
+
|
1297
|
+
if self.mode == "edit" and event.button() == Qt.MouseButton.LeftButton:
|
1298
|
+
pos = event.scenePos()
|
1299
|
+
if self.viewer._pixmap_item.pixmap().rect().contains(pos.toPoint()):
|
1300
|
+
self.is_dragging_polygon = True
|
1301
|
+
self.drag_start_pos = pos
|
1302
|
+
selected_indices = self.right_panel.get_selected_segment_indices()
|
1303
|
+
self.drag_initial_vertices = {
|
1304
|
+
i: [
|
1305
|
+
QPointF(p) for p in self.segment_manager.segments[i]["vertices"]
|
1306
|
+
]
|
1307
|
+
for i in selected_indices
|
1308
|
+
if self.segment_manager.segments[i].get("type") == "Polygon"
|
1309
|
+
}
|
1310
|
+
event.accept()
|
1311
|
+
return
|
1312
|
+
|
1313
|
+
# Call the original scene handler.
|
1314
|
+
self._original_mouse_press(event)
|
1315
|
+
# Skip further processing unless we're in selection mode.
|
1316
|
+
if event.isAccepted() and self.mode != "selection":
|
1317
|
+
return
|
1318
|
+
|
1319
|
+
if self.is_dragging_polygon:
|
1320
|
+
return
|
1321
|
+
|
1322
|
+
pos = event.scenePos()
|
1323
|
+
if (
|
1324
|
+
self.viewer._pixmap_item.pixmap().isNull()
|
1325
|
+
or not self.viewer._pixmap_item.pixmap().rect().contains(pos.toPoint())
|
1326
|
+
):
|
1327
|
+
return
|
1328
|
+
|
1329
|
+
if self.mode == "pan":
|
1330
|
+
self.viewer.set_cursor(Qt.CursorShape.ClosedHandCursor)
|
1331
|
+
elif self.mode == "sam_points":
|
1332
|
+
if event.button() == Qt.MouseButton.LeftButton:
|
1333
|
+
self._add_point(pos, positive=True)
|
1334
|
+
self._update_segmentation()
|
1335
|
+
elif event.button() == Qt.MouseButton.RightButton:
|
1336
|
+
self._add_point(pos, positive=False)
|
1337
|
+
self._update_segmentation()
|
1338
|
+
elif self.mode == "polygon":
|
1339
|
+
if event.button() == Qt.MouseButton.LeftButton:
|
1340
|
+
self._handle_polygon_click(pos)
|
1341
|
+
elif self.mode == "selection" and event.button() == Qt.MouseButton.LeftButton:
|
1342
|
+
self._handle_segment_selection_click(pos)
|
1343
|
+
|
1344
|
+
def _scene_mouse_move(self, event):
|
1345
|
+
"""Handle mouse move events in the scene."""
|
1346
|
+
if self.mode == "edit" and self.is_dragging_polygon:
|
1347
|
+
delta = event.scenePos() - self.drag_start_pos
|
1348
|
+
for i, initial_verts in self.drag_initial_vertices.items():
|
1349
|
+
self.segment_manager.segments[i]["vertices"] = [
|
1350
|
+
QPointF(v) + delta for v in initial_verts
|
1351
|
+
]
|
1352
|
+
self._update_polygon_item(i)
|
1353
|
+
self._display_edit_handles() # Redraw handles at new positions
|
1354
|
+
self._highlight_selected_segments() # Redraw highlight at new position
|
1355
|
+
event.accept()
|
1356
|
+
return
|
1357
|
+
|
1358
|
+
self._original_mouse_move(event)
|
1359
|
+
|
1360
|
+
def _scene_mouse_release(self, event):
|
1361
|
+
"""Handle mouse release events in the scene."""
|
1362
|
+
if self.mode == "edit" and self.is_dragging_polygon:
|
1363
|
+
# Record the action for undo
|
1364
|
+
final_vertices = {
|
1365
|
+
i: list(self.segment_manager.segments[i]["vertices"])
|
1366
|
+
for i in self.drag_initial_vertices
|
1367
|
+
}
|
1368
|
+
self.action_history.append(
|
1369
|
+
{
|
1370
|
+
"type": "move_polygon",
|
1371
|
+
"initial_vertices": {
|
1372
|
+
k: list(v) for k, v in self.drag_initial_vertices.items()
|
1373
|
+
},
|
1374
|
+
"final_vertices": final_vertices,
|
1375
|
+
}
|
1376
|
+
)
|
1377
|
+
# Clear redo history when a new action is performed
|
1378
|
+
self.redo_history.clear()
|
1379
|
+
self.is_dragging_polygon = False
|
1380
|
+
self.drag_initial_vertices.clear()
|
1381
|
+
event.accept()
|
1382
|
+
return
|
1383
|
+
|
1384
|
+
if self.mode == "pan":
|
1385
|
+
self.viewer.set_cursor(Qt.CursorShape.OpenHandCursor)
|
1386
|
+
self._original_mouse_release(event)
|
1387
|
+
|
1388
|
+
def _add_point(self, pos, positive):
|
1389
|
+
"""Add a point for SAM segmentation."""
|
1390
|
+
point_list = self.positive_points if positive else self.negative_points
|
1391
|
+
point_list.append([int(pos.x()), int(pos.y())])
|
1392
|
+
|
1393
|
+
point_color = (
|
1394
|
+
QColor(Qt.GlobalColor.green) if positive else QColor(Qt.GlobalColor.red)
|
1395
|
+
)
|
1396
|
+
point_color.setAlpha(150)
|
1397
|
+
point_diameter = self.point_radius * 2
|
1398
|
+
|
1399
|
+
point_item = QGraphicsEllipseItem(
|
1400
|
+
pos.x() - self.point_radius,
|
1401
|
+
pos.y() - self.point_radius,
|
1402
|
+
point_diameter,
|
1403
|
+
point_diameter,
|
1404
|
+
)
|
1405
|
+
point_item.setBrush(QBrush(point_color))
|
1406
|
+
point_item.setPen(QPen(Qt.GlobalColor.transparent))
|
1407
|
+
self.viewer.scene().addItem(point_item)
|
1408
|
+
self.point_items.append(point_item)
|
1409
|
+
|
1410
|
+
# Record the action for undo
|
1411
|
+
self.action_history.append(
|
1412
|
+
{
|
1413
|
+
"type": "add_point",
|
1414
|
+
"point_type": "positive" if positive else "negative",
|
1415
|
+
"point_coords": [int(pos.x()), int(pos.y())],
|
1416
|
+
"point_item": point_item,
|
1417
|
+
}
|
1418
|
+
)
|
1419
|
+
# Clear redo history when a new action is performed
|
1420
|
+
self.redo_history.clear()
|
1421
|
+
|
1422
|
+
def _update_segmentation(self):
|
1423
|
+
"""Update SAM segmentation preview."""
|
1424
|
+
if hasattr(self, "preview_mask_item") and self.preview_mask_item:
|
1425
|
+
self.viewer.scene().removeItem(self.preview_mask_item)
|
1426
|
+
if not self.positive_points or not self.model_manager.is_model_available():
|
1427
|
+
return
|
1428
|
+
|
1429
|
+
mask = self.model_manager.sam_model.predict(
|
1430
|
+
self.positive_points, self.negative_points
|
1431
|
+
)
|
1432
|
+
if mask is not None:
|
1433
|
+
pixmap = mask_to_pixmap(mask, (255, 255, 0))
|
1434
|
+
self.preview_mask_item = self.viewer.scene().addPixmap(pixmap)
|
1435
|
+
self.preview_mask_item.setZValue(50)
|
1436
|
+
|
1437
|
+
def _handle_polygon_click(self, pos):
|
1438
|
+
"""Handle polygon drawing clicks."""
|
1439
|
+
# Check if clicking near the first point to close polygon
|
1440
|
+
if self.polygon_points and len(self.polygon_points) > 2:
|
1441
|
+
first_point = self.polygon_points[0]
|
1442
|
+
distance_squared = (pos.x() - first_point.x()) ** 2 + (
|
1443
|
+
pos.y() - first_point.y()
|
1444
|
+
) ** 2
|
1445
|
+
if distance_squared < self.polygon_join_threshold**2:
|
1446
|
+
self._finalize_polygon()
|
1447
|
+
return
|
1448
|
+
|
1449
|
+
# Add new point to polygon
|
1450
|
+
self.polygon_points.append(pos)
|
1451
|
+
|
1452
|
+
# Create visual point
|
1453
|
+
point_diameter = self.point_radius * 2
|
1454
|
+
point_color = QColor(Qt.GlobalColor.blue)
|
1455
|
+
point_color.setAlpha(150)
|
1456
|
+
dot = QGraphicsEllipseItem(
|
1457
|
+
pos.x() - self.point_radius,
|
1458
|
+
pos.y() - self.point_radius,
|
1459
|
+
point_diameter,
|
1460
|
+
point_diameter,
|
1461
|
+
)
|
1462
|
+
dot.setBrush(QBrush(point_color))
|
1463
|
+
dot.setPen(QPen(Qt.GlobalColor.transparent))
|
1464
|
+
self.viewer.scene().addItem(dot)
|
1465
|
+
self.polygon_preview_items.append(dot)
|
1466
|
+
|
1467
|
+
# Update polygon preview
|
1468
|
+
self._draw_polygon_preview()
|
1469
|
+
|
1470
|
+
# Record the action for undo
|
1471
|
+
self.action_history.append(
|
1472
|
+
{
|
1473
|
+
"type": "add_polygon_point",
|
1474
|
+
"point_coords": pos,
|
1475
|
+
"dot_item": dot,
|
1476
|
+
}
|
1477
|
+
)
|
1478
|
+
# Clear redo history when a new action is performed
|
1479
|
+
self.redo_history.clear()
|
1480
|
+
|
1481
|
+
def _draw_polygon_preview(self):
|
1482
|
+
"""Draw polygon preview lines and fill."""
|
1483
|
+
# Remove old preview lines and polygons (keep dots)
|
1484
|
+
for item in self.polygon_preview_items[:]:
|
1485
|
+
if not isinstance(item, QGraphicsEllipseItem):
|
1486
|
+
if item.scene():
|
1487
|
+
self.viewer.scene().removeItem(item)
|
1488
|
+
self.polygon_preview_items.remove(item)
|
1489
|
+
|
1490
|
+
if len(self.polygon_points) > 2:
|
1491
|
+
# Create preview polygon fill
|
1492
|
+
preview_poly = QGraphicsPolygonItem(QPolygonF(self.polygon_points))
|
1493
|
+
preview_poly.setBrush(QBrush(QColor(0, 255, 255, 100)))
|
1494
|
+
preview_poly.setPen(QPen(Qt.GlobalColor.transparent))
|
1495
|
+
self.viewer.scene().addItem(preview_poly)
|
1496
|
+
self.polygon_preview_items.append(preview_poly)
|
1497
|
+
|
1498
|
+
if len(self.polygon_points) > 1:
|
1499
|
+
# Create preview lines between points
|
1500
|
+
line_color = QColor(Qt.GlobalColor.cyan)
|
1501
|
+
line_color.setAlpha(150)
|
1502
|
+
for i in range(len(self.polygon_points) - 1):
|
1503
|
+
line = QGraphicsLineItem(
|
1504
|
+
self.polygon_points[i].x(),
|
1505
|
+
self.polygon_points[i].y(),
|
1506
|
+
self.polygon_points[i + 1].x(),
|
1507
|
+
self.polygon_points[i + 1].y(),
|
1508
|
+
)
|
1509
|
+
line.setPen(QPen(line_color, self.line_thickness))
|
1510
|
+
self.viewer.scene().addItem(line)
|
1511
|
+
self.polygon_preview_items.append(line)
|
1512
|
+
|
1513
|
+
def _handle_segment_selection_click(self, pos):
|
1514
|
+
"""Handle segment selection clicks (toggle behavior)."""
|
1515
|
+
x, y = int(pos.x()), int(pos.y())
|
1516
|
+
for i in range(len(self.segment_manager.segments) - 1, -1, -1):
|
1517
|
+
seg = self.segment_manager.segments[i]
|
1518
|
+
# Determine mask for hit-testing
|
1519
|
+
if seg["type"] == "Polygon" and seg.get("vertices"):
|
1520
|
+
# Rasterize polygon
|
1521
|
+
if self.viewer._pixmap_item.pixmap().isNull():
|
1522
|
+
continue
|
1523
|
+
h = self.viewer._pixmap_item.pixmap().height()
|
1524
|
+
w = self.viewer._pixmap_item.pixmap().width()
|
1525
|
+
points_np = np.array(
|
1526
|
+
[[p.x(), p.y()] for p in seg["vertices"]], dtype=np.int32
|
1527
|
+
)
|
1528
|
+
# Ensure points are within bounds
|
1529
|
+
points_np = np.clip(points_np, 0, [w - 1, h - 1])
|
1530
|
+
mask = np.zeros((h, w), dtype=np.uint8)
|
1531
|
+
cv2.fillPoly(mask, [points_np], 1)
|
1532
|
+
mask = mask.astype(bool)
|
1533
|
+
else:
|
1534
|
+
mask = seg.get("mask")
|
1535
|
+
if (
|
1536
|
+
mask is not None
|
1537
|
+
and y < mask.shape[0]
|
1538
|
+
and x < mask.shape[1]
|
1539
|
+
and mask[y, x]
|
1540
|
+
):
|
1541
|
+
# Find the corresponding row in the segment table and toggle selection
|
1542
|
+
table = self.right_panel.segment_table
|
1543
|
+
for j in range(table.rowCount()):
|
1544
|
+
item = table.item(j, 0)
|
1545
|
+
if item and item.data(Qt.ItemDataRole.UserRole) == i:
|
1546
|
+
# Toggle selection for this row using the original working method
|
1547
|
+
is_selected = table.item(j, 0).isSelected()
|
1548
|
+
range_to_select = QTableWidgetSelectionRange(
|
1549
|
+
j, 0, j, table.columnCount() - 1
|
1550
|
+
)
|
1551
|
+
table.setRangeSelected(range_to_select, not is_selected)
|
1552
|
+
self._highlight_selected_segments()
|
1553
|
+
return
|
1554
|
+
self.viewer.setFocus()
|
1555
|
+
|
1556
|
+
def _get_color_for_class(self, class_id):
|
1557
|
+
"""Get color for a class ID."""
|
1558
|
+
if class_id is None:
|
1559
|
+
return QColor.fromHsv(0, 0, 128)
|
1560
|
+
hue = int((class_id * 222.4922359) % 360)
|
1561
|
+
color = QColor.fromHsv(hue, 220, 220)
|
1562
|
+
if not color.isValid():
|
1563
|
+
return QColor(Qt.GlobalColor.white)
|
1564
|
+
return color
|
1565
|
+
|
1566
|
+
def _display_edit_handles(self):
|
1567
|
+
"""Display draggable vertex handles for selected polygons in edit mode."""
|
1568
|
+
self._clear_edit_handles()
|
1569
|
+
if self.mode != "edit":
|
1570
|
+
return
|
1571
|
+
selected_indices = self.right_panel.get_selected_segment_indices()
|
1572
|
+
handle_radius = self.point_radius
|
1573
|
+
handle_diam = handle_radius * 2
|
1574
|
+
for seg_idx in selected_indices:
|
1575
|
+
seg = self.segment_manager.segments[seg_idx]
|
1576
|
+
if seg["type"] == "Polygon" and seg.get("vertices"):
|
1577
|
+
for v_idx, pt in enumerate(seg["vertices"]):
|
1578
|
+
handle = EditableVertexItem(
|
1579
|
+
self,
|
1580
|
+
seg_idx,
|
1581
|
+
v_idx,
|
1582
|
+
-handle_radius,
|
1583
|
+
-handle_radius,
|
1584
|
+
handle_diam,
|
1585
|
+
handle_diam,
|
1586
|
+
)
|
1587
|
+
handle.setPos(pt) # Use setPos to handle zoom correctly
|
1588
|
+
handle.setZValue(200) # Ensure handles are on top
|
1589
|
+
# Make sure the handle can receive mouse events
|
1590
|
+
handle.setAcceptHoverEvents(True)
|
1591
|
+
self.viewer.scene().addItem(handle)
|
1592
|
+
self.edit_handles.append(handle)
|
1593
|
+
|
1594
|
+
def _clear_edit_handles(self):
|
1595
|
+
"""Remove all editable vertex handles from the scene."""
|
1596
|
+
if hasattr(self, "edit_handles"):
|
1597
|
+
for h in self.edit_handles:
|
1598
|
+
if h.scene():
|
1599
|
+
self.viewer.scene().removeItem(h)
|
1600
|
+
self.edit_handles = []
|
1601
|
+
|
1602
|
+
def update_vertex_pos(self, segment_index, vertex_index, new_pos, record_undo=True):
|
1603
|
+
"""Update the position of a vertex in a polygon segment."""
|
1604
|
+
seg = self.segment_manager.segments[segment_index]
|
1605
|
+
if seg.get("type") == "Polygon":
|
1606
|
+
old_pos = seg["vertices"][vertex_index]
|
1607
|
+
if record_undo:
|
1608
|
+
self.action_history.append(
|
1609
|
+
{
|
1610
|
+
"type": "move_vertex",
|
1611
|
+
"segment_index": segment_index,
|
1612
|
+
"vertex_index": vertex_index,
|
1613
|
+
"old_pos": old_pos,
|
1614
|
+
"new_pos": new_pos,
|
1615
|
+
}
|
1616
|
+
)
|
1617
|
+
# Clear redo history when a new action is performed
|
1618
|
+
self.redo_history.clear()
|
1619
|
+
seg["vertices"][vertex_index] = (
|
1620
|
+
new_pos # new_pos is already the correct scene coordinate
|
1621
|
+
)
|
1622
|
+
self._update_polygon_item(segment_index)
|
1623
|
+
self._highlight_selected_segments() # Keep the highlight in sync with the new shape
|
1624
|
+
|
1625
|
+
def _update_polygon_item(self, segment_index):
|
1626
|
+
"""Efficiently update the visual polygon item for a given segment."""
|
1627
|
+
items = self.segment_items.get(segment_index, [])
|
1628
|
+
for item in items:
|
1629
|
+
if isinstance(item, HoverablePolygonItem):
|
1630
|
+
item.setPolygon(
|
1631
|
+
QPolygonF(self.segment_manager.segments[segment_index]["vertices"])
|
1632
|
+
)
|
1633
|
+
return
|
1634
|
+
|
1635
|
+
def _handle_class_toggle(self, class_id):
|
1636
|
+
"""Handle class toggle."""
|
1637
|
+
is_active = self.segment_manager.toggle_active_class(class_id)
|
1638
|
+
|
1639
|
+
if is_active:
|
1640
|
+
self._show_notification(f"Class {class_id} activated for new segments")
|
1641
|
+
# Update visual display
|
1642
|
+
self.right_panel.update_active_class_display(class_id)
|
1643
|
+
else:
|
1644
|
+
self._show_notification(
|
1645
|
+
"No active class - new segments will create new classes"
|
1646
|
+
)
|
1647
|
+
# Update visual display to clear active class
|
1648
|
+
self.right_panel.update_active_class_display(None)
|
1649
|
+
|
1650
|
+
def _pop_out_left_panel(self):
|
1651
|
+
"""Pop out the left control panel into a separate window."""
|
1652
|
+
if self.left_panel_popout is not None:
|
1653
|
+
# Panel is already popped out, return it to main window
|
1654
|
+
self._return_left_panel(self.control_panel)
|
1655
|
+
return
|
1656
|
+
|
1657
|
+
# Remove panel from main splitter
|
1658
|
+
self.control_panel.setParent(None)
|
1659
|
+
|
1660
|
+
# Create pop-out window
|
1661
|
+
self.left_panel_popout = PanelPopoutWindow(
|
1662
|
+
self.control_panel, "Control Panel", self
|
1663
|
+
)
|
1664
|
+
self.left_panel_popout.panel_closed.connect(self._return_left_panel)
|
1665
|
+
self.left_panel_popout.show()
|
1666
|
+
|
1667
|
+
# Update panel's pop-out button
|
1668
|
+
self.control_panel.set_popout_mode(True)
|
1669
|
+
|
1670
|
+
# Make pop-out window resizable
|
1671
|
+
self.left_panel_popout.setMinimumSize(200, 400)
|
1672
|
+
self.left_panel_popout.resize(self.control_panel.preferred_width + 20, 600)
|
1673
|
+
|
1674
|
+
def _pop_out_right_panel(self):
|
1675
|
+
"""Pop out the right panel into a separate window."""
|
1676
|
+
if self.right_panel_popout is not None:
|
1677
|
+
# Panel is already popped out, return it to main window
|
1678
|
+
self._return_right_panel(self.right_panel)
|
1679
|
+
return
|
1680
|
+
|
1681
|
+
# Remove panel from main splitter
|
1682
|
+
self.right_panel.setParent(None)
|
1683
|
+
|
1684
|
+
# Create pop-out window
|
1685
|
+
self.right_panel_popout = PanelPopoutWindow(
|
1686
|
+
self.right_panel, "File Explorer & Segments", self
|
1687
|
+
)
|
1688
|
+
self.right_panel_popout.panel_closed.connect(self._return_right_panel)
|
1689
|
+
self.right_panel_popout.show()
|
1690
|
+
|
1691
|
+
# Update panel's pop-out button
|
1692
|
+
self.right_panel.set_popout_mode(True)
|
1693
|
+
|
1694
|
+
# Make pop-out window resizable
|
1695
|
+
self.right_panel_popout.setMinimumSize(250, 400)
|
1696
|
+
self.right_panel_popout.resize(self.right_panel.preferred_width + 20, 600)
|
1697
|
+
|
1698
|
+
def _return_left_panel(self, panel_widget):
|
1699
|
+
"""Return the left panel to the main window."""
|
1700
|
+
if self.left_panel_popout is not None:
|
1701
|
+
# Close the pop-out window
|
1702
|
+
self.left_panel_popout.close()
|
1703
|
+
|
1704
|
+
# Return panel to main splitter
|
1705
|
+
self.main_splitter.insertWidget(0, self.control_panel)
|
1706
|
+
self.left_panel_popout = None
|
1707
|
+
|
1708
|
+
# Update panel's pop-out button
|
1709
|
+
self.control_panel.set_popout_mode(False)
|
1710
|
+
|
1711
|
+
# Restore splitter sizes
|
1712
|
+
self.main_splitter.setSizes([250, 800, 350])
|
1713
|
+
|
1714
|
+
def _handle_splitter_moved(self, pos, index):
|
1715
|
+
"""Handle splitter movement for intelligent expand/collapse behavior."""
|
1716
|
+
sizes = self.main_splitter.sizes()
|
1717
|
+
|
1718
|
+
# Left panel (index 0) - expand/collapse logic
|
1719
|
+
if index == 1: # Splitter between left panel and viewer
|
1720
|
+
left_size = sizes[0]
|
1721
|
+
# Only snap to collapsed if user drags very close to collapse
|
1722
|
+
if left_size < 50: # Collapsed threshold
|
1723
|
+
# Panel is being collapsed, snap to collapsed state
|
1724
|
+
new_sizes = [0] + sizes[1:]
|
1725
|
+
new_sizes[1] = new_sizes[1] + left_size # Give space back to viewer
|
1726
|
+
self.main_splitter.setSizes(new_sizes)
|
1727
|
+
# Temporarily override minimum width to allow collapsing
|
1728
|
+
self.control_panel.setMinimumWidth(0)
|
1729
|
+
|
1730
|
+
# Right panel (index 2) - expand/collapse logic
|
1731
|
+
elif index == 2: # Splitter between viewer and right panel
|
1732
|
+
right_size = sizes[2]
|
1733
|
+
# Only snap to collapsed if user drags very close to collapse
|
1734
|
+
if right_size < 50: # Collapsed threshold
|
1735
|
+
# Panel is being collapsed, snap to collapsed state
|
1736
|
+
new_sizes = sizes[:-1] + [0]
|
1737
|
+
new_sizes[1] = new_sizes[1] + right_size # Give space back to viewer
|
1738
|
+
self.main_splitter.setSizes(new_sizes)
|
1739
|
+
# Temporarily override minimum width to allow collapsing
|
1740
|
+
self.right_panel.setMinimumWidth(0)
|
1741
|
+
|
1742
|
+
def _expand_left_panel(self):
|
1743
|
+
"""Expand the left panel to its preferred width."""
|
1744
|
+
sizes = self.main_splitter.sizes()
|
1745
|
+
if sizes[0] < 50: # Only expand if currently collapsed
|
1746
|
+
# Restore minimum width first
|
1747
|
+
self.control_panel.setMinimumWidth(self.control_panel.preferred_width)
|
1748
|
+
|
1749
|
+
space_needed = self.control_panel.preferred_width
|
1750
|
+
viewer_width = sizes[1] - space_needed
|
1751
|
+
if viewer_width > 400: # Ensure viewer has minimum space
|
1752
|
+
new_sizes = [self.control_panel.preferred_width, viewer_width] + sizes[
|
1753
|
+
2:
|
1754
|
+
]
|
1755
|
+
self.main_splitter.setSizes(new_sizes)
|
1756
|
+
|
1757
|
+
def _expand_right_panel(self):
|
1758
|
+
"""Expand the right panel to its preferred width."""
|
1759
|
+
sizes = self.main_splitter.sizes()
|
1760
|
+
if sizes[2] < 50: # Only expand if currently collapsed
|
1761
|
+
# Restore minimum width first
|
1762
|
+
self.right_panel.setMinimumWidth(self.right_panel.preferred_width)
|
1763
|
+
|
1764
|
+
space_needed = self.right_panel.preferred_width
|
1765
|
+
viewer_width = sizes[1] - space_needed
|
1766
|
+
if viewer_width > 400: # Ensure viewer has minimum space
|
1767
|
+
new_sizes = sizes[:-1] + [
|
1768
|
+
viewer_width,
|
1769
|
+
self.right_panel.preferred_width,
|
1770
|
+
]
|
1771
|
+
self.main_splitter.setSizes(new_sizes)
|
1772
|
+
|
1773
|
+
def _return_right_panel(self, panel_widget):
|
1774
|
+
"""Return the right panel to the main window."""
|
1775
|
+
if self.right_panel_popout is not None:
|
1776
|
+
# Close the pop-out window
|
1777
|
+
self.right_panel_popout.close()
|
1778
|
+
|
1779
|
+
# Return panel to main splitter
|
1780
|
+
self.main_splitter.addWidget(self.right_panel)
|
1781
|
+
self.right_panel_popout = None
|
1782
|
+
|
1783
|
+
# Update panel's pop-out button
|
1784
|
+
self.right_panel.set_popout_mode(False)
|
1785
|
+
|
1786
|
+
# Restore splitter sizes
|
1787
|
+
self.main_splitter.setSizes([250, 800, 350])
|