lazylabel-gui 1.0.8__py3-none-any.whl → 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lazylabel/__init__.py +9 -0
- lazylabel/config/__init__.py +7 -0
- lazylabel/config/hotkeys.py +169 -0
- lazylabel/config/paths.py +41 -0
- lazylabel/config/settings.py +66 -0
- lazylabel/core/__init__.py +7 -0
- lazylabel/core/file_manager.py +106 -0
- lazylabel/core/model_manager.py +94 -0
- lazylabel/core/segment_manager.py +140 -0
- lazylabel/main.py +10 -1246
- lazylabel/models/__init__.py +5 -0
- lazylabel/models/sam_model.py +154 -0
- lazylabel/ui/__init__.py +8 -0
- lazylabel/ui/control_panel.py +220 -0
- lazylabel/{editable_vertex.py → ui/editable_vertex.py} +25 -3
- lazylabel/ui/hotkey_dialog.py +384 -0
- lazylabel/{hoverable_polygon_item.py → ui/hoverable_polygon_item.py} +17 -1
- lazylabel/ui/main_window.py +1264 -0
- lazylabel/ui/right_panel.py +239 -0
- lazylabel/ui/widgets/__init__.py +7 -0
- lazylabel/ui/widgets/adjustments_widget.py +107 -0
- lazylabel/ui/widgets/model_selection_widget.py +94 -0
- lazylabel/ui/widgets/settings_widget.py +106 -0
- lazylabel/utils/__init__.py +6 -0
- lazylabel/utils/custom_file_system_model.py +132 -0
- {lazylabel_gui-1.0.8.dist-info → lazylabel_gui-1.1.0.dist-info}/METADATA +62 -12
- lazylabel_gui-1.1.0.dist-info/RECORD +36 -0
- lazylabel/controls.py +0 -261
- lazylabel/custom_file_system_model.py +0 -72
- lazylabel/sam_model.py +0 -70
- lazylabel_gui-1.0.8.dist-info/RECORD +0 -17
- /lazylabel/{hoverable_pixelmap_item.py → ui/hoverable_pixelmap_item.py} +0 -0
- /lazylabel/{numeric_table_widget_item.py → ui/numeric_table_widget_item.py} +0 -0
- /lazylabel/{photo_viewer.py → ui/photo_viewer.py} +0 -0
- /lazylabel/{reorderable_class_table.py → ui/reorderable_class_table.py} +0 -0
- /lazylabel/{utils.py → utils/utils.py} +0 -0
- {lazylabel_gui-1.0.8.dist-info → lazylabel_gui-1.1.0.dist-info}/WHEEL +0 -0
- {lazylabel_gui-1.0.8.dist-info → lazylabel_gui-1.1.0.dist-info}/entry_points.txt +0 -0
- {lazylabel_gui-1.0.8.dist-info → lazylabel_gui-1.1.0.dist-info}/licenses/LICENSE +0 -0
- {lazylabel_gui-1.0.8.dist-info → lazylabel_gui-1.1.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,154 @@
|
|
1
|
+
import os
|
2
|
+
import cv2
|
3
|
+
import numpy as np
|
4
|
+
import torch
|
5
|
+
import requests
|
6
|
+
from tqdm import tqdm
|
7
|
+
from segment_anything import sam_model_registry, SamPredictor
|
8
|
+
|
9
|
+
|
10
|
+
def download_model(url, download_path):
|
11
|
+
"""Downloads file with a progress bar."""
|
12
|
+
print(
|
13
|
+
f"SAM model not found. Downloading from Meta's GitHub repository to: {download_path}"
|
14
|
+
)
|
15
|
+
try:
|
16
|
+
response = requests.get(url, stream=True, timeout=30)
|
17
|
+
response.raise_for_status()
|
18
|
+
total_size_in_bytes = int(response.headers.get("content-length", 0))
|
19
|
+
block_size = 1024 # 1 Kibibyte
|
20
|
+
|
21
|
+
progress_bar = tqdm(total=total_size_in_bytes, unit="iB", unit_scale=True)
|
22
|
+
with open(download_path, "wb") as file:
|
23
|
+
for data in response.iter_content(block_size):
|
24
|
+
progress_bar.update(len(data))
|
25
|
+
file.write(data)
|
26
|
+
progress_bar.close()
|
27
|
+
|
28
|
+
if total_size_in_bytes != 0 and progress_bar.n != total_size_in_bytes:
|
29
|
+
raise RuntimeError("Download incomplete - file size mismatch")
|
30
|
+
|
31
|
+
print("Model download completed successfully.")
|
32
|
+
|
33
|
+
except requests.exceptions.RequestException as e:
|
34
|
+
raise RuntimeError(f"Network error during download: {e}")
|
35
|
+
except Exception as e:
|
36
|
+
# Clean up partial download
|
37
|
+
if os.path.exists(download_path):
|
38
|
+
os.remove(download_path)
|
39
|
+
raise RuntimeError(f"Download failed: {e}")
|
40
|
+
|
41
|
+
|
42
|
+
class SamModel:
|
43
|
+
def __init__(self, model_type="vit_h", model_filename="sam_vit_h_4b8939.pth", custom_model_path=None):
|
44
|
+
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
45
|
+
self.current_model_type = model_type
|
46
|
+
self.current_model_path = custom_model_path
|
47
|
+
self.model = None
|
48
|
+
self.predictor = None
|
49
|
+
self.image = None
|
50
|
+
self.is_loaded = False
|
51
|
+
|
52
|
+
try:
|
53
|
+
if custom_model_path and os.path.exists(custom_model_path):
|
54
|
+
# Use custom model path
|
55
|
+
model_path = custom_model_path
|
56
|
+
print(f"Loading custom SAM model from {model_path}...")
|
57
|
+
else:
|
58
|
+
# Use default model with download if needed - store in models folder
|
59
|
+
model_url = f"https://dl.fbaipublicfiles.com/segment_anything/{model_filename}"
|
60
|
+
|
61
|
+
# Use models folder instead of cache folder
|
62
|
+
models_dir = os.path.dirname(__file__) # Already in models directory
|
63
|
+
os.makedirs(models_dir, exist_ok=True)
|
64
|
+
model_path = os.path.join(models_dir, model_filename)
|
65
|
+
|
66
|
+
# Also check the old cache location and move it if it exists
|
67
|
+
old_cache_dir = os.path.join(os.path.expanduser("~"), ".cache", "lazylabel")
|
68
|
+
old_model_path = os.path.join(old_cache_dir, model_filename)
|
69
|
+
|
70
|
+
if os.path.exists(old_model_path) and not os.path.exists(model_path):
|
71
|
+
print(f"Moving existing model from cache to models folder...")
|
72
|
+
import shutil
|
73
|
+
shutil.move(old_model_path, model_path)
|
74
|
+
elif not os.path.exists(model_path):
|
75
|
+
# Download the model if it doesn't exist
|
76
|
+
download_model(model_url, model_path)
|
77
|
+
|
78
|
+
print(f"Loading default SAM model from {model_path}...")
|
79
|
+
|
80
|
+
self.model = sam_model_registry[model_type](checkpoint=model_path).to(
|
81
|
+
self.device
|
82
|
+
)
|
83
|
+
self.predictor = SamPredictor(self.model)
|
84
|
+
self.is_loaded = True
|
85
|
+
print("SAM model loaded successfully.")
|
86
|
+
|
87
|
+
except Exception as e:
|
88
|
+
print(f"Failed to load SAM model: {e}")
|
89
|
+
print("SAM point functionality will be disabled.")
|
90
|
+
self.is_loaded = False
|
91
|
+
|
92
|
+
def load_custom_model(self, model_path, model_type="vit_h"):
|
93
|
+
"""Load a custom model from the specified path."""
|
94
|
+
if not os.path.exists(model_path):
|
95
|
+
print(f"Model file not found: {model_path}")
|
96
|
+
return False
|
97
|
+
|
98
|
+
print(f"Loading custom SAM model from {model_path}...")
|
99
|
+
try:
|
100
|
+
# Clear existing model from memory
|
101
|
+
if hasattr(self, 'model') and self.model is not None:
|
102
|
+
del self.model
|
103
|
+
del self.predictor
|
104
|
+
torch.cuda.empty_cache() if torch.cuda.is_available() else None
|
105
|
+
|
106
|
+
# Load new model
|
107
|
+
self.model = sam_model_registry[model_type](checkpoint=model_path).to(self.device)
|
108
|
+
self.predictor = SamPredictor(self.model)
|
109
|
+
self.current_model_type = model_type
|
110
|
+
self.current_model_path = model_path
|
111
|
+
self.is_loaded = True
|
112
|
+
|
113
|
+
# Re-set image if one was previously loaded
|
114
|
+
if self.image is not None:
|
115
|
+
self.predictor.set_image(self.image)
|
116
|
+
|
117
|
+
print("Custom SAM model loaded successfully.")
|
118
|
+
return True
|
119
|
+
except Exception as e:
|
120
|
+
print(f"Error loading custom model: {e}")
|
121
|
+
self.is_loaded = False
|
122
|
+
self.model = None
|
123
|
+
self.predictor = None
|
124
|
+
return False
|
125
|
+
|
126
|
+
def set_image(self, image_path):
|
127
|
+
if not self.is_loaded:
|
128
|
+
return False
|
129
|
+
try:
|
130
|
+
self.image = cv2.imread(image_path)
|
131
|
+
self.image = cv2.cvtColor(self.image, cv2.COLOR_BGR2RGB)
|
132
|
+
self.predictor.set_image(self.image)
|
133
|
+
return True
|
134
|
+
except Exception as e:
|
135
|
+
print(f"Error setting image: {e}")
|
136
|
+
return False
|
137
|
+
|
138
|
+
def predict(self, positive_points, negative_points):
|
139
|
+
if not self.is_loaded or not positive_points:
|
140
|
+
return None
|
141
|
+
|
142
|
+
try:
|
143
|
+
points = np.array(positive_points + negative_points)
|
144
|
+
labels = np.array([1] * len(positive_points) + [0] * len(negative_points))
|
145
|
+
|
146
|
+
masks, _, _ = self.predictor.predict(
|
147
|
+
point_coords=points,
|
148
|
+
point_labels=labels,
|
149
|
+
multimask_output=False,
|
150
|
+
)
|
151
|
+
return masks[0]
|
152
|
+
except Exception as e:
|
153
|
+
print(f"Error during prediction: {e}")
|
154
|
+
return None
|
lazylabel/ui/__init__.py
ADDED
@@ -0,0 +1,8 @@
|
|
1
|
+
"""UI components for LazyLabel."""
|
2
|
+
|
3
|
+
from .main_window import MainWindow
|
4
|
+
from .control_panel import ControlPanel
|
5
|
+
from .right_panel import RightPanel
|
6
|
+
from .hotkey_dialog import HotkeyDialog
|
7
|
+
|
8
|
+
__all__ = ['MainWindow', 'ControlPanel', 'RightPanel', 'HotkeyDialog']
|
@@ -0,0 +1,220 @@
|
|
1
|
+
"""Left control panel with mode controls and settings."""
|
2
|
+
|
3
|
+
from PyQt6.QtWidgets import (
|
4
|
+
QWidget, QVBoxLayout, QPushButton, QLabel, QFrame, QHBoxLayout,
|
5
|
+
QCheckBox, QSlider, QGroupBox, QComboBox
|
6
|
+
)
|
7
|
+
from PyQt6.QtCore import Qt, pyqtSignal
|
8
|
+
|
9
|
+
from .widgets import ModelSelectionWidget, SettingsWidget, AdjustmentsWidget
|
10
|
+
|
11
|
+
|
12
|
+
class ControlPanel(QWidget):
|
13
|
+
"""Left control panel with mode controls and settings."""
|
14
|
+
|
15
|
+
# Signals
|
16
|
+
sam_mode_requested = pyqtSignal()
|
17
|
+
polygon_mode_requested = pyqtSignal()
|
18
|
+
selection_mode_requested = pyqtSignal()
|
19
|
+
clear_points_requested = pyqtSignal()
|
20
|
+
fit_view_requested = pyqtSignal()
|
21
|
+
browse_models_requested = pyqtSignal()
|
22
|
+
refresh_models_requested = pyqtSignal()
|
23
|
+
model_selected = pyqtSignal(str)
|
24
|
+
annotation_size_changed = pyqtSignal(int)
|
25
|
+
pan_speed_changed = pyqtSignal(int)
|
26
|
+
join_threshold_changed = pyqtSignal(int)
|
27
|
+
hotkeys_requested = pyqtSignal()
|
28
|
+
|
29
|
+
def __init__(self, parent=None):
|
30
|
+
super().__init__(parent)
|
31
|
+
self.setFixedWidth(250)
|
32
|
+
self._setup_ui()
|
33
|
+
self._connect_signals()
|
34
|
+
|
35
|
+
def _setup_ui(self):
|
36
|
+
"""Setup the UI layout."""
|
37
|
+
layout = QVBoxLayout(self)
|
38
|
+
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
39
|
+
|
40
|
+
# Toggle button
|
41
|
+
toggle_layout = QHBoxLayout()
|
42
|
+
self.btn_toggle_visibility = QPushButton("< Hide")
|
43
|
+
self.btn_toggle_visibility.setToolTip("Hide this panel")
|
44
|
+
toggle_layout.addWidget(self.btn_toggle_visibility)
|
45
|
+
toggle_layout.addStretch()
|
46
|
+
layout.addLayout(toggle_layout)
|
47
|
+
|
48
|
+
# Main controls widget
|
49
|
+
self.main_controls_widget = QWidget()
|
50
|
+
main_layout = QVBoxLayout(self.main_controls_widget)
|
51
|
+
main_layout.setContentsMargins(0, 0, 0, 0)
|
52
|
+
|
53
|
+
# Mode label
|
54
|
+
self.mode_label = QLabel("Mode: Points")
|
55
|
+
font = self.mode_label.font()
|
56
|
+
font.setPointSize(14)
|
57
|
+
font.setBold(True)
|
58
|
+
self.mode_label.setFont(font)
|
59
|
+
main_layout.addWidget(self.mode_label)
|
60
|
+
|
61
|
+
# Mode buttons
|
62
|
+
self._add_mode_buttons(main_layout)
|
63
|
+
|
64
|
+
# Separator
|
65
|
+
main_layout.addSpacing(20)
|
66
|
+
main_layout.addWidget(self._create_separator())
|
67
|
+
main_layout.addSpacing(10)
|
68
|
+
|
69
|
+
# Model selection
|
70
|
+
self.model_widget = ModelSelectionWidget()
|
71
|
+
main_layout.addWidget(self.model_widget)
|
72
|
+
|
73
|
+
# Separator
|
74
|
+
main_layout.addSpacing(10)
|
75
|
+
main_layout.addWidget(self._create_separator())
|
76
|
+
main_layout.addSpacing(10)
|
77
|
+
|
78
|
+
# Action buttons
|
79
|
+
self._add_action_buttons(main_layout)
|
80
|
+
main_layout.addSpacing(10)
|
81
|
+
|
82
|
+
# Settings
|
83
|
+
self.settings_widget = SettingsWidget()
|
84
|
+
main_layout.addWidget(self.settings_widget)
|
85
|
+
|
86
|
+
# Adjustments
|
87
|
+
self.adjustments_widget = AdjustmentsWidget()
|
88
|
+
main_layout.addWidget(self.adjustments_widget)
|
89
|
+
|
90
|
+
main_layout.addStretch()
|
91
|
+
|
92
|
+
# Status labels
|
93
|
+
self.notification_label = QLabel("")
|
94
|
+
font = self.notification_label.font()
|
95
|
+
font.setItalic(True)
|
96
|
+
self.notification_label.setFont(font)
|
97
|
+
self.notification_label.setStyleSheet("color: #ffa500;")
|
98
|
+
self.notification_label.setWordWrap(True)
|
99
|
+
main_layout.addWidget(self.notification_label)
|
100
|
+
|
101
|
+
self.device_label = QLabel("Device: Unknown")
|
102
|
+
main_layout.addWidget(self.device_label)
|
103
|
+
|
104
|
+
layout.addWidget(self.main_controls_widget)
|
105
|
+
|
106
|
+
def _add_mode_buttons(self, layout):
|
107
|
+
"""Add mode control buttons."""
|
108
|
+
self.btn_sam_mode = QPushButton("Point Mode (1)")
|
109
|
+
self.btn_sam_mode.setToolTip("Switch to Point Mode for AI segmentation (1)")
|
110
|
+
|
111
|
+
self.btn_polygon_mode = QPushButton("Polygon Mode (2)")
|
112
|
+
self.btn_polygon_mode.setToolTip("Switch to Polygon Drawing Mode (2)")
|
113
|
+
|
114
|
+
self.btn_selection_mode = QPushButton("Selection Mode (E)")
|
115
|
+
self.btn_selection_mode.setToolTip("Toggle segment selection (E)")
|
116
|
+
|
117
|
+
layout.addWidget(self.btn_sam_mode)
|
118
|
+
layout.addWidget(self.btn_polygon_mode)
|
119
|
+
layout.addWidget(self.btn_selection_mode)
|
120
|
+
|
121
|
+
def _add_action_buttons(self, layout):
|
122
|
+
"""Add action buttons."""
|
123
|
+
self.btn_fit_view = QPushButton("Fit View (.)")
|
124
|
+
self.btn_fit_view.setToolTip("Reset image zoom and pan to fit the view (.)")
|
125
|
+
|
126
|
+
self.btn_clear_points = QPushButton("Clear Clicks (C)")
|
127
|
+
self.btn_clear_points.setToolTip("Clear current temporary points/vertices (C)")
|
128
|
+
|
129
|
+
self.btn_hotkeys = QPushButton("Hotkeys")
|
130
|
+
self.btn_hotkeys.setToolTip("Configure keyboard shortcuts")
|
131
|
+
|
132
|
+
layout.addWidget(self.btn_fit_view)
|
133
|
+
layout.addWidget(self.btn_clear_points)
|
134
|
+
layout.addWidget(self.btn_hotkeys)
|
135
|
+
|
136
|
+
def _create_separator(self):
|
137
|
+
"""Create a horizontal separator line."""
|
138
|
+
line = QFrame()
|
139
|
+
line.setFrameShape(QFrame.Shape.HLine)
|
140
|
+
return line
|
141
|
+
|
142
|
+
def _connect_signals(self):
|
143
|
+
"""Connect internal signals."""
|
144
|
+
self.btn_sam_mode.clicked.connect(self.sam_mode_requested)
|
145
|
+
self.btn_polygon_mode.clicked.connect(self.polygon_mode_requested)
|
146
|
+
self.btn_selection_mode.clicked.connect(self.selection_mode_requested)
|
147
|
+
self.btn_clear_points.clicked.connect(self.clear_points_requested)
|
148
|
+
self.btn_fit_view.clicked.connect(self.fit_view_requested)
|
149
|
+
self.btn_hotkeys.clicked.connect(self.hotkeys_requested)
|
150
|
+
|
151
|
+
# Model widget signals
|
152
|
+
self.model_widget.browse_requested.connect(self.browse_models_requested)
|
153
|
+
self.model_widget.refresh_requested.connect(self.refresh_models_requested)
|
154
|
+
self.model_widget.model_selected.connect(self.model_selected)
|
155
|
+
|
156
|
+
# Settings widget signals
|
157
|
+
self.adjustments_widget.annotation_size_changed.connect(self.annotation_size_changed)
|
158
|
+
self.adjustments_widget.pan_speed_changed.connect(self.pan_speed_changed)
|
159
|
+
self.adjustments_widget.join_threshold_changed.connect(self.join_threshold_changed)
|
160
|
+
|
161
|
+
def toggle_visibility(self):
|
162
|
+
"""Toggle panel visibility."""
|
163
|
+
is_visible = self.main_controls_widget.isVisible()
|
164
|
+
self.main_controls_widget.setVisible(not is_visible)
|
165
|
+
if is_visible:
|
166
|
+
self.btn_toggle_visibility.setText("> Show")
|
167
|
+
self.setFixedWidth(self.btn_toggle_visibility.sizeHint().width() + 20)
|
168
|
+
else:
|
169
|
+
self.btn_toggle_visibility.setText("< Hide")
|
170
|
+
self.setFixedWidth(250)
|
171
|
+
|
172
|
+
def show_notification(self, message: str, duration: int = 3000):
|
173
|
+
"""Show a notification message."""
|
174
|
+
self.notification_label.setText(message)
|
175
|
+
# Note: Timer should be handled by the caller
|
176
|
+
|
177
|
+
def clear_notification(self):
|
178
|
+
"""Clear the notification message."""
|
179
|
+
self.notification_label.clear()
|
180
|
+
|
181
|
+
def set_mode_text(self, mode: str):
|
182
|
+
"""Set the mode label text."""
|
183
|
+
self.mode_label.setText(f"Mode: {mode.replace('_', ' ').title()}")
|
184
|
+
|
185
|
+
def set_device_text(self, device: str):
|
186
|
+
"""Set the device label text."""
|
187
|
+
self.device_label.setText(f"Device: {device.upper()}")
|
188
|
+
|
189
|
+
# Delegate methods for sub-widgets
|
190
|
+
def populate_models(self, models):
|
191
|
+
"""Populate the models combo box."""
|
192
|
+
self.model_widget.populate_models(models)
|
193
|
+
|
194
|
+
def set_current_model(self, model_name):
|
195
|
+
"""Set the current model display."""
|
196
|
+
self.model_widget.set_current_model(model_name)
|
197
|
+
|
198
|
+
def get_settings(self):
|
199
|
+
"""Get current settings from the settings widget."""
|
200
|
+
return self.settings_widget.get_settings()
|
201
|
+
|
202
|
+
def set_settings(self, settings):
|
203
|
+
"""Set settings in the settings widget."""
|
204
|
+
self.settings_widget.set_settings(settings)
|
205
|
+
|
206
|
+
def get_annotation_size(self):
|
207
|
+
"""Get current annotation size."""
|
208
|
+
return self.adjustments_widget.get_annotation_size()
|
209
|
+
|
210
|
+
def set_annotation_size(self, value):
|
211
|
+
"""Set annotation size."""
|
212
|
+
self.adjustments_widget.set_annotation_size(value)
|
213
|
+
|
214
|
+
def set_sam_mode_enabled(self, enabled: bool):
|
215
|
+
"""Enable or disable the SAM mode button."""
|
216
|
+
self.btn_sam_mode.setEnabled(enabled)
|
217
|
+
if not enabled:
|
218
|
+
self.btn_sam_mode.setToolTip("Point Mode (SAM model not available)")
|
219
|
+
else:
|
220
|
+
self.btn_sam_mode.setToolTip("Switch to Point Mode for AI segmentation (1)")
|
@@ -17,13 +17,35 @@ class EditableVertexItem(QGraphicsEllipseItem):
|
|
17
17
|
self.setBrush(QBrush(color))
|
18
18
|
|
19
19
|
self.setPen(QPen(Qt.GlobalColor.transparent))
|
20
|
+
|
21
|
+
# Set flags for dragging - use the original working approach
|
20
22
|
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
|
21
23
|
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
|
24
|
+
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
|
25
|
+
|
26
|
+
# Accept mouse events
|
27
|
+
self.setAcceptHoverEvents(True)
|
22
28
|
|
23
29
|
def itemChange(self, change, value):
|
24
30
|
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
|
25
31
|
new_pos = value
|
26
|
-
self.main_window
|
27
|
-
self.
|
28
|
-
|
32
|
+
if hasattr(self.main_window, "update_vertex_pos"):
|
33
|
+
self.main_window.update_vertex_pos(
|
34
|
+
self.segment_index, self.vertex_index, new_pos
|
35
|
+
)
|
29
36
|
return super().itemChange(change, value)
|
37
|
+
|
38
|
+
def mousePressEvent(self, event):
|
39
|
+
"""Handle mouse press events."""
|
40
|
+
super().mousePressEvent(event)
|
41
|
+
event.accept()
|
42
|
+
|
43
|
+
def mouseMoveEvent(self, event):
|
44
|
+
"""Handle mouse move events."""
|
45
|
+
super().mouseMoveEvent(event)
|
46
|
+
event.accept()
|
47
|
+
|
48
|
+
def mouseReleaseEvent(self, event):
|
49
|
+
"""Handle mouse release events."""
|
50
|
+
super().mouseReleaseEvent(event)
|
51
|
+
event.accept()
|