lazylabel-gui 1.2.0__py3-none-any.whl → 1.3.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/config/settings.py +3 -0
- lazylabel/main.py +1 -0
- lazylabel/models/sam2_model.py +166 -18
- lazylabel/ui/control_panel.py +17 -15
- lazylabel/ui/editable_vertex.py +72 -0
- lazylabel/ui/hoverable_pixelmap_item.py +25 -0
- lazylabel/ui/hoverable_polygon_item.py +26 -0
- lazylabel/ui/main_window.py +5560 -239
- lazylabel/ui/modes/__init__.py +6 -0
- lazylabel/ui/modes/base_mode.py +52 -0
- lazylabel/ui/modes/multi_view_mode.py +1173 -0
- lazylabel/ui/modes/single_view_mode.py +299 -0
- lazylabel/ui/photo_viewer.py +31 -3
- lazylabel/ui/test_hover.py +48 -0
- lazylabel/ui/widgets/adjustments_widget.py +2 -2
- lazylabel/ui/widgets/border_crop_widget.py +11 -0
- lazylabel/ui/widgets/channel_threshold_widget.py +50 -6
- lazylabel/ui/widgets/fft_threshold_widget.py +116 -22
- lazylabel/ui/widgets/model_selection_widget.py +117 -4
- {lazylabel_gui-1.2.0.dist-info → lazylabel_gui-1.3.0.dist-info}/METADATA +194 -200
- {lazylabel_gui-1.2.0.dist-info → lazylabel_gui-1.3.0.dist-info}/RECORD +25 -20
- {lazylabel_gui-1.2.0.dist-info → lazylabel_gui-1.3.0.dist-info}/WHEEL +0 -0
- {lazylabel_gui-1.2.0.dist-info → lazylabel_gui-1.3.0.dist-info}/entry_points.txt +0 -0
- {lazylabel_gui-1.2.0.dist-info → lazylabel_gui-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {lazylabel_gui-1.2.0.dist-info → lazylabel_gui-1.3.0.dist-info}/top_level.txt +0 -0
lazylabel/config/settings.py
CHANGED
@@ -42,6 +42,9 @@ class Settings:
|
|
42
42
|
# UI State
|
43
43
|
annotation_size_multiplier: float = 1.0
|
44
44
|
|
45
|
+
# Multi-view Settings
|
46
|
+
multi_view_grid_mode: str = "2_view" # "2_view" or "4_view"
|
47
|
+
|
45
48
|
def save_to_file(self, filepath: str) -> None:
|
46
49
|
"""Save settings to JSON file."""
|
47
50
|
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
lazylabel/main.py
CHANGED
lazylabel/models/sam2_model.py
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
import os
|
2
|
+
from pathlib import Path
|
2
3
|
|
3
4
|
import cv2
|
4
5
|
import numpy as np
|
@@ -45,8 +46,91 @@ class Sam2Model:
|
|
45
46
|
logger.info(f"SAM2: Loading model from {model_path}...")
|
46
47
|
logger.info(f"SAM2: Using config: {config_path}")
|
47
48
|
|
49
|
+
# Ensure config_path is absolute
|
50
|
+
if not os.path.isabs(config_path):
|
51
|
+
# Try to make it absolute if it's relative
|
52
|
+
import sam2
|
53
|
+
|
54
|
+
sam2_dir = os.path.dirname(sam2.__file__)
|
55
|
+
config_path = os.path.join(sam2_dir, "configs", config_path)
|
56
|
+
|
57
|
+
# Verify the config exists before passing to build_sam2
|
58
|
+
if not os.path.exists(config_path):
|
59
|
+
raise FileNotFoundError(f"Config file not found: {config_path}")
|
60
|
+
|
61
|
+
logger.info(f"SAM2: Resolved config path: {config_path}")
|
62
|
+
|
48
63
|
# Build SAM2 model
|
49
|
-
|
64
|
+
# SAM2 uses Hydra for configuration - we need to pass the right config name
|
65
|
+
# Try different approaches based on what's available
|
66
|
+
|
67
|
+
model_filename = Path(model_path).name.lower()
|
68
|
+
|
69
|
+
# First, try using the auto-detected config path directly
|
70
|
+
try:
|
71
|
+
logger.info(f"SAM2: Attempting to load with config path: {config_path}")
|
72
|
+
self.model = self._build_sam2_with_fallback(config_path, model_path)
|
73
|
+
logger.info("SAM2: Successfully loaded with config path")
|
74
|
+
except Exception as e1:
|
75
|
+
logger.debug(f"SAM2: Config path approach failed: {e1}")
|
76
|
+
|
77
|
+
# Second, try just the config filename without path
|
78
|
+
try:
|
79
|
+
config_filename = Path(config_path).name
|
80
|
+
logger.info(
|
81
|
+
f"SAM2: Attempting to load with config filename: {config_filename}"
|
82
|
+
)
|
83
|
+
self.model = self._build_sam2_with_fallback(
|
84
|
+
config_filename, model_path
|
85
|
+
)
|
86
|
+
logger.info("SAM2: Successfully loaded with config filename")
|
87
|
+
except Exception as e2:
|
88
|
+
logger.debug(f"SAM2: Config filename approach failed: {e2}")
|
89
|
+
|
90
|
+
# Third, try the base config name without version
|
91
|
+
try:
|
92
|
+
# Map model sizes to base config names
|
93
|
+
if (
|
94
|
+
"tiny" in model_filename
|
95
|
+
or "_t." in model_filename
|
96
|
+
or "_t_" in model_filename
|
97
|
+
):
|
98
|
+
base_config = "sam2_hiera_t.yaml"
|
99
|
+
elif (
|
100
|
+
"small" in model_filename
|
101
|
+
or "_s." in model_filename
|
102
|
+
or "_s_" in model_filename
|
103
|
+
):
|
104
|
+
base_config = "sam2_hiera_s.yaml"
|
105
|
+
elif (
|
106
|
+
"base_plus" in model_filename
|
107
|
+
or "_b+." in model_filename
|
108
|
+
or "_b+_" in model_filename
|
109
|
+
):
|
110
|
+
base_config = "sam2_hiera_b+.yaml"
|
111
|
+
elif (
|
112
|
+
"large" in model_filename
|
113
|
+
or "_l." in model_filename
|
114
|
+
or "_l_" in model_filename
|
115
|
+
):
|
116
|
+
base_config = "sam2_hiera_l.yaml"
|
117
|
+
else:
|
118
|
+
base_config = "sam2_hiera_l.yaml"
|
119
|
+
|
120
|
+
logger.info(
|
121
|
+
f"SAM2: Attempting to load with base config: {base_config}"
|
122
|
+
)
|
123
|
+
self.model = self._build_sam2_with_fallback(
|
124
|
+
base_config, model_path
|
125
|
+
)
|
126
|
+
logger.info("SAM2: Successfully loaded with base config")
|
127
|
+
except Exception as e3:
|
128
|
+
# All approaches failed
|
129
|
+
raise Exception(
|
130
|
+
f"Failed to load SAM2 model with any config approach. "
|
131
|
+
f"Tried: {config_path}, {config_filename}, {base_config}. "
|
132
|
+
f"Last error: {e3}"
|
133
|
+
) from e3
|
50
134
|
|
51
135
|
# Create predictor
|
52
136
|
self.predictor = SAM2ImagePredictor(self.model)
|
@@ -61,14 +145,15 @@ class Sam2Model:
|
|
61
145
|
|
62
146
|
def _auto_detect_config(self, model_path: str) -> str:
|
63
147
|
"""Auto-detect the appropriate config file based on model filename."""
|
64
|
-
|
148
|
+
model_path = Path(model_path)
|
149
|
+
filename = model_path.name.lower()
|
65
150
|
|
66
151
|
# Get the sam2 package directory
|
67
152
|
try:
|
68
153
|
import sam2
|
69
154
|
|
70
|
-
sam2_dir =
|
71
|
-
configs_dir =
|
155
|
+
sam2_dir = Path(sam2.__file__).parent
|
156
|
+
configs_dir = sam2_dir / "configs"
|
72
157
|
|
73
158
|
# Map model types to config files
|
74
159
|
if "tiny" in filename or "_t" in filename:
|
@@ -95,26 +180,89 @@ class Sam2Model:
|
|
95
180
|
|
96
181
|
# Check sam2.1 configs first, then fall back to sam2
|
97
182
|
if "2.1" in filename:
|
98
|
-
config_path =
|
183
|
+
config_path = configs_dir / "sam2.1" / config_file
|
99
184
|
else:
|
100
|
-
config_path =
|
101
|
-
configs_dir, "sam2", config_file.replace("2.1_", "")
|
102
|
-
)
|
185
|
+
config_path = configs_dir / "sam2" / config_file.replace("2.1_", "")
|
103
186
|
|
104
|
-
|
105
|
-
|
187
|
+
logger.debug(f"SAM2: Checking config path: {config_path}")
|
188
|
+
if config_path.exists():
|
189
|
+
return str(config_path.absolute())
|
106
190
|
|
107
191
|
# Fallback to default large config
|
108
|
-
fallback_config =
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
192
|
+
fallback_config = configs_dir / "sam2.1" / "sam2.1_hiera_l.yaml"
|
193
|
+
logger.debug(f"SAM2: Checking fallback config: {fallback_config}")
|
194
|
+
if fallback_config.exists():
|
195
|
+
return str(fallback_config.absolute())
|
196
|
+
|
197
|
+
# Try without version subdirectory
|
198
|
+
direct_config = configs_dir / config_file
|
199
|
+
logger.debug(f"SAM2: Checking direct config: {direct_config}")
|
200
|
+
if direct_config.exists():
|
201
|
+
return str(direct_config.absolute())
|
202
|
+
|
203
|
+
raise FileNotFoundError(
|
204
|
+
f"No suitable config found for {filename} in {configs_dir}"
|
205
|
+
)
|
113
206
|
|
114
207
|
except Exception as e:
|
115
208
|
logger.error(f"SAM2: Failed to auto-detect config: {e}")
|
116
|
-
#
|
117
|
-
|
209
|
+
# Try to construct a full path even if auto-detection failed
|
210
|
+
try:
|
211
|
+
import sam2
|
212
|
+
|
213
|
+
sam2_dir = Path(sam2.__file__).parent
|
214
|
+
# Return full path to default config
|
215
|
+
return str(sam2_dir / "configs" / "sam2.1" / "sam2.1_hiera_l.yaml")
|
216
|
+
except Exception:
|
217
|
+
# Last resort - return just the config name and let hydra handle it
|
218
|
+
return "sam2.1_hiera_l.yaml"
|
219
|
+
|
220
|
+
def _build_sam2_with_fallback(self, config_path, model_path):
|
221
|
+
"""Build SAM2 model with fallback for state_dict compatibility issues."""
|
222
|
+
try:
|
223
|
+
# First, try the standard build_sam2 approach
|
224
|
+
return build_sam2(config_path, model_path, device=self.device)
|
225
|
+
except RuntimeError as e:
|
226
|
+
if "Unexpected key(s) in state_dict" in str(e):
|
227
|
+
logger.warning(f"SAM2: Detected state_dict compatibility issue: {e}")
|
228
|
+
logger.info("SAM2: Attempting to load with state_dict filtering...")
|
229
|
+
|
230
|
+
# Build model without loading weights first
|
231
|
+
model = build_sam2(config_path, None, device=self.device)
|
232
|
+
|
233
|
+
# Load checkpoint and handle nested structure
|
234
|
+
checkpoint = torch.load(model_path, map_location=self.device)
|
235
|
+
|
236
|
+
# Check if checkpoint has nested 'model' key (common in SAM2.1)
|
237
|
+
if "model" in checkpoint and isinstance(checkpoint["model"], dict):
|
238
|
+
logger.info(
|
239
|
+
"SAM2: Detected nested checkpoint structure, extracting model weights"
|
240
|
+
)
|
241
|
+
model_weights = checkpoint["model"]
|
242
|
+
else:
|
243
|
+
# Flat structure - filter out the known problematic keys
|
244
|
+
model_weights = {}
|
245
|
+
problematic_keys = {
|
246
|
+
"no_obj_embed_spatial",
|
247
|
+
"obj_ptr_tpos_proj.weight",
|
248
|
+
"obj_ptr_tpos_proj.bias",
|
249
|
+
}
|
250
|
+
for key, value in checkpoint.items():
|
251
|
+
if key not in problematic_keys:
|
252
|
+
model_weights[key] = value
|
253
|
+
|
254
|
+
logger.info(
|
255
|
+
f"SAM2: Filtered out problematic keys: {list(problematic_keys & set(checkpoint.keys()))}"
|
256
|
+
)
|
257
|
+
|
258
|
+
# Load the model weights
|
259
|
+
model.load_state_dict(model_weights, strict=False)
|
260
|
+
logger.info("SAM2: Successfully loaded model with state_dict filtering")
|
261
|
+
|
262
|
+
return model
|
263
|
+
else:
|
264
|
+
# Re-raise if it's a different type of error
|
265
|
+
raise
|
118
266
|
|
119
267
|
def set_image_from_path(self, image_path: str) -> bool:
|
120
268
|
"""Set image for SAM2 model from file path."""
|
@@ -204,7 +352,7 @@ class Sam2Model:
|
|
204
352
|
config_path = self._auto_detect_config(model_path)
|
205
353
|
|
206
354
|
# Load new model
|
207
|
-
self.model =
|
355
|
+
self.model = self._build_sam2_with_fallback(config_path, model_path)
|
208
356
|
self.predictor = SAM2ImagePredictor(self.model)
|
209
357
|
self.current_model_path = model_path
|
210
358
|
self.is_loaded = True
|
lazylabel/ui/control_panel.py
CHANGED
@@ -48,7 +48,7 @@ class SimpleCollapsible(QWidget):
|
|
48
48
|
border: 1px solid rgba(120, 120, 120, 0.5);
|
49
49
|
background: rgba(70, 70, 70, 0.6);
|
50
50
|
color: #E0E0E0;
|
51
|
-
font-size:
|
51
|
+
font-size: 11px;
|
52
52
|
font-weight: bold;
|
53
53
|
border-radius: 2px;
|
54
54
|
}
|
@@ -71,7 +71,7 @@ class SimpleCollapsible(QWidget):
|
|
71
71
|
QLabel {
|
72
72
|
color: #E0E0E0;
|
73
73
|
font-weight: bold;
|
74
|
-
font-size:
|
74
|
+
font-size: 12px;
|
75
75
|
background: transparent;
|
76
76
|
border: none;
|
77
77
|
padding: 2px;
|
@@ -202,8 +202,8 @@ class ControlPanel(QWidget):
|
|
202
202
|
|
203
203
|
def __init__(self, parent=None):
|
204
204
|
super().__init__(parent)
|
205
|
-
self.setMinimumWidth(
|
206
|
-
self.preferred_width =
|
205
|
+
self.setMinimumWidth(300) # Wider for better text fitting
|
206
|
+
self.preferred_width = 320
|
207
207
|
self._setup_ui()
|
208
208
|
self._connect_signals()
|
209
209
|
|
@@ -274,9 +274,9 @@ class ControlPanel(QWidget):
|
|
274
274
|
padding: 4px 8px;
|
275
275
|
margin-right: 1px;
|
276
276
|
color: #B0B0B0;
|
277
|
-
font-size:
|
278
|
-
min-width:
|
279
|
-
max-width:
|
277
|
+
font-size: 11px;
|
278
|
+
min-width: 50px;
|
279
|
+
max-width: 100px;
|
280
280
|
}
|
281
281
|
QTabBar::tab:selected {
|
282
282
|
background-color: rgba(70, 70, 70, 0.9);
|
@@ -307,7 +307,7 @@ class ControlPanel(QWidget):
|
|
307
307
|
QLabel {
|
308
308
|
color: #FFA500;
|
309
309
|
font-style: italic;
|
310
|
-
font-size:
|
310
|
+
font-size: 10px;
|
311
311
|
background: transparent;
|
312
312
|
border: none;
|
313
313
|
padding: 4px;
|
@@ -389,7 +389,7 @@ class ControlPanel(QWidget):
|
|
389
389
|
button = QPushButton(f"{text} ({key})")
|
390
390
|
button.setToolTip(f"{tooltip} ({key})")
|
391
391
|
button.setFixedHeight(28)
|
392
|
-
button.setFixedWidth(
|
392
|
+
button.setFixedWidth(90) # Wider for better text fitting
|
393
393
|
button.setStyleSheet(
|
394
394
|
"""
|
395
395
|
QPushButton {
|
@@ -398,7 +398,7 @@ class ControlPanel(QWidget):
|
|
398
398
|
border-radius: 6px;
|
399
399
|
color: #E0E0E0;
|
400
400
|
font-weight: bold;
|
401
|
-
font-size:
|
401
|
+
font-size: 11px;
|
402
402
|
padding: 4px 8px;
|
403
403
|
}
|
404
404
|
QPushButton:hover {
|
@@ -434,7 +434,7 @@ class ControlPanel(QWidget):
|
|
434
434
|
button = QPushButton(button_text)
|
435
435
|
button.setToolTip(tooltip_text)
|
436
436
|
button.setFixedHeight(28)
|
437
|
-
button.setFixedWidth(
|
437
|
+
button.setFixedWidth(90) # Wider for better text fitting
|
438
438
|
button.setStyleSheet(
|
439
439
|
"""
|
440
440
|
QPushButton {
|
@@ -443,7 +443,7 @@ class ControlPanel(QWidget):
|
|
443
443
|
border-radius: 6px;
|
444
444
|
color: #E0E0E0;
|
445
445
|
font-weight: bold;
|
446
|
-
font-size:
|
446
|
+
font-size: 11px;
|
447
447
|
padding: 4px 8px;
|
448
448
|
}
|
449
449
|
QPushButton:hover {
|
@@ -476,7 +476,7 @@ class ControlPanel(QWidget):
|
|
476
476
|
border-radius: 6px;
|
477
477
|
color: #E0E0E0;
|
478
478
|
font-weight: bold;
|
479
|
-
font-size:
|
479
|
+
font-size: 11px;
|
480
480
|
padding: 4px 8px;
|
481
481
|
min-height: 22px;
|
482
482
|
}
|
@@ -497,7 +497,7 @@ class ControlPanel(QWidget):
|
|
497
497
|
border: 1px solid rgba(100, 100, 100, 0.6);
|
498
498
|
border-radius: 5px;
|
499
499
|
color: #E0E0E0;
|
500
|
-
font-size:
|
500
|
+
font-size: 11px;
|
501
501
|
padding: 4px 8px;
|
502
502
|
min-height: 22px;
|
503
503
|
}
|
@@ -617,10 +617,11 @@ class ControlPanel(QWidget):
|
|
617
617
|
)
|
618
618
|
layout.addWidget(threshold_collapsible)
|
619
619
|
|
620
|
-
# FFT Threshold - collapsible
|
620
|
+
# FFT Threshold - collapsible (default collapsed)
|
621
621
|
self.fft_threshold_collapsible = SimpleCollapsible(
|
622
622
|
"FFT Threshold", self.fft_threshold_widget
|
623
623
|
)
|
624
|
+
self.fft_threshold_collapsible.set_collapsed(True) # Default to collapsed
|
624
625
|
layout.addWidget(self.fft_threshold_collapsible)
|
625
626
|
|
626
627
|
# Image Adjustments - collapsible
|
@@ -761,6 +762,7 @@ class ControlPanel(QWidget):
|
|
761
762
|
# Map internal mode names to buttons
|
762
763
|
mode_buttons = {
|
763
764
|
"sam_points": self.btn_sam_mode,
|
765
|
+
"ai": self.btn_sam_mode, # AI mode uses the same button as SAM mode
|
764
766
|
"polygon": self.btn_polygon_mode,
|
765
767
|
"bbox": self.btn_bbox_mode,
|
766
768
|
"selection": self.btn_selection_mode,
|
lazylabel/ui/editable_vertex.py
CHANGED
@@ -62,3 +62,75 @@ class EditableVertexItem(QGraphicsEllipseItem):
|
|
62
62
|
self.initial_pos = None
|
63
63
|
super().mouseReleaseEvent(event)
|
64
64
|
event.accept()
|
65
|
+
|
66
|
+
|
67
|
+
class MultiViewEditableVertexItem(QGraphicsEllipseItem):
|
68
|
+
"""Editable vertex item for multi-view mode that can sync changes across viewers."""
|
69
|
+
|
70
|
+
def __init__(
|
71
|
+
self, main_window, segment_index, vertex_index, viewer_index, x, y, w, h
|
72
|
+
):
|
73
|
+
super().__init__(x, y, w, h)
|
74
|
+
self.main_window = main_window
|
75
|
+
self.segment_index = segment_index
|
76
|
+
self.vertex_index = vertex_index
|
77
|
+
self.viewer_index = viewer_index
|
78
|
+
self.initial_pos = None
|
79
|
+
|
80
|
+
self.setZValue(200)
|
81
|
+
|
82
|
+
color = QColor(Qt.GlobalColor.cyan)
|
83
|
+
color.setAlpha(180)
|
84
|
+
self.setBrush(QBrush(color))
|
85
|
+
|
86
|
+
self.setPen(QPen(Qt.GlobalColor.transparent))
|
87
|
+
|
88
|
+
# Set flags for dragging
|
89
|
+
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
|
90
|
+
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
|
91
|
+
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
|
92
|
+
|
93
|
+
# Accept mouse events
|
94
|
+
self.setAcceptHoverEvents(True)
|
95
|
+
|
96
|
+
def itemChange(self, change, value):
|
97
|
+
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
|
98
|
+
new_pos = value
|
99
|
+
if hasattr(self.main_window, "update_multi_view_vertex_pos"):
|
100
|
+
self.main_window.update_multi_view_vertex_pos(
|
101
|
+
self.segment_index,
|
102
|
+
self.vertex_index,
|
103
|
+
self.viewer_index,
|
104
|
+
new_pos,
|
105
|
+
record_undo=False,
|
106
|
+
)
|
107
|
+
return super().itemChange(change, value)
|
108
|
+
|
109
|
+
def mousePressEvent(self, event):
|
110
|
+
"""Handle mouse press events."""
|
111
|
+
self.initial_pos = self.pos()
|
112
|
+
super().mousePressEvent(event)
|
113
|
+
event.accept()
|
114
|
+
|
115
|
+
def mouseMoveEvent(self, event):
|
116
|
+
"""Handle mouse move events."""
|
117
|
+
super().mouseMoveEvent(event)
|
118
|
+
event.accept()
|
119
|
+
|
120
|
+
def mouseReleaseEvent(self, event):
|
121
|
+
"""Handle mouse release events."""
|
122
|
+
if self.initial_pos and self.initial_pos != self.pos():
|
123
|
+
self.main_window.action_history.append(
|
124
|
+
{
|
125
|
+
"type": "move_vertex",
|
126
|
+
"viewer_mode": "multi",
|
127
|
+
"viewer_index": self.viewer_index,
|
128
|
+
"segment_index": self.segment_index,
|
129
|
+
"vertex_index": self.vertex_index,
|
130
|
+
"old_pos": [self.initial_pos.x(), self.initial_pos.y()],
|
131
|
+
"new_pos": [self.pos().x(), self.pos().y()],
|
132
|
+
}
|
133
|
+
)
|
134
|
+
self.initial_pos = None
|
135
|
+
super().mouseReleaseEvent(event)
|
136
|
+
event.accept()
|
@@ -7,16 +7,41 @@ class HoverablePixmapItem(QGraphicsPixmapItem):
|
|
7
7
|
self.setAcceptHoverEvents(True)
|
8
8
|
self.default_pixmap = None
|
9
9
|
self.hover_pixmap = None
|
10
|
+
self.segment_id = None
|
11
|
+
self.main_window = None
|
10
12
|
|
11
13
|
def set_pixmaps(self, default_pixmap, hover_pixmap):
|
12
14
|
self.default_pixmap = default_pixmap
|
13
15
|
self.hover_pixmap = hover_pixmap
|
14
16
|
self.setPixmap(self.default_pixmap)
|
15
17
|
|
18
|
+
def set_segment_info(self, segment_id, main_window):
|
19
|
+
self.segment_id = segment_id
|
20
|
+
self.main_window = main_window
|
21
|
+
|
22
|
+
def set_hover_state(self, hover_state):
|
23
|
+
"""Set hover state without triggering hover events."""
|
24
|
+
self.setPixmap(self.hover_pixmap if hover_state else self.default_pixmap)
|
25
|
+
|
16
26
|
def hoverEnterEvent(self, event):
|
17
27
|
self.setPixmap(self.hover_pixmap)
|
28
|
+
# Trigger hover on mirror segments in multi-view mode
|
29
|
+
if (
|
30
|
+
self.main_window
|
31
|
+
and hasattr(self.main_window, "view_mode")
|
32
|
+
and self.main_window.view_mode == "multi"
|
33
|
+
and hasattr(self.main_window, "_trigger_segment_hover")
|
34
|
+
):
|
35
|
+
self.main_window._trigger_segment_hover(self.segment_id, True, self)
|
18
36
|
super().hoverEnterEvent(event)
|
19
37
|
|
20
38
|
def hoverLeaveEvent(self, event):
|
21
39
|
self.setPixmap(self.default_pixmap)
|
40
|
+
# Trigger unhover on mirror segments in multi-view mode
|
41
|
+
if (
|
42
|
+
self.main_window
|
43
|
+
and hasattr(self.main_window, "view_mode")
|
44
|
+
and self.main_window.view_mode == "multi"
|
45
|
+
):
|
46
|
+
self.main_window._trigger_segment_hover(self.segment_id, False, self)
|
22
47
|
super().hoverLeaveEvent(event)
|
@@ -8,18 +8,44 @@ class HoverablePolygonItem(QGraphicsPolygonItem):
|
|
8
8
|
self.setAcceptHoverEvents(True)
|
9
9
|
self.default_brush = QBrush()
|
10
10
|
self.hover_brush = QBrush()
|
11
|
+
self.segment_id = None
|
12
|
+
self.main_window = None
|
11
13
|
|
12
14
|
def set_brushes(self, default_brush, hover_brush):
|
13
15
|
self.default_brush = default_brush
|
14
16
|
self.hover_brush = hover_brush
|
15
17
|
self.setBrush(self.default_brush)
|
16
18
|
|
19
|
+
def set_segment_info(self, segment_id, main_window):
|
20
|
+
self.segment_id = segment_id
|
21
|
+
self.main_window = main_window
|
22
|
+
|
23
|
+
def set_hover_state(self, hover_state):
|
24
|
+
"""Set hover state without triggering hover events."""
|
25
|
+
self.setBrush(self.hover_brush if hover_state else self.default_brush)
|
26
|
+
|
17
27
|
def hoverEnterEvent(self, event):
|
18
28
|
self.setBrush(self.hover_brush)
|
29
|
+
|
30
|
+
# Trigger hover on mirror segments in multi-view mode
|
31
|
+
if (
|
32
|
+
self.main_window
|
33
|
+
and hasattr(self.main_window, "view_mode")
|
34
|
+
and self.main_window.view_mode == "multi"
|
35
|
+
and hasattr(self.main_window, "_trigger_segment_hover")
|
36
|
+
):
|
37
|
+
self.main_window._trigger_segment_hover(self.segment_id, True, self)
|
19
38
|
super().hoverEnterEvent(event)
|
20
39
|
|
21
40
|
def hoverLeaveEvent(self, event):
|
22
41
|
self.setBrush(self.default_brush)
|
42
|
+
# Trigger unhover on mirror segments in multi-view mode
|
43
|
+
if (
|
44
|
+
self.main_window
|
45
|
+
and hasattr(self.main_window, "view_mode")
|
46
|
+
and self.main_window.view_mode == "multi"
|
47
|
+
):
|
48
|
+
self.main_window._trigger_segment_hover(self.segment_id, False, self)
|
23
49
|
super().hoverLeaveEvent(event)
|
24
50
|
|
25
51
|
def mousePressEvent(self, event):
|