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.
@@ -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
@@ -23,6 +23,7 @@ def main():
23
23
  logger.info("Step 3/8: Applying dark theme...")
24
24
  qdarktheme.setup_theme()
25
25
 
26
+ logger.info("Step 4/8: Setting up main window...")
26
27
  main_window = MainWindow()
27
28
 
28
29
  logger.info("Step 7/8: Showing main window...")
@@ -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
- self.model = build_sam2(config_path, model_path, device=self.device)
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
- filename = os.path.basename(model_path).lower()
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 = os.path.dirname(sam2.__file__)
71
- configs_dir = os.path.join(sam2_dir, "configs")
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 = os.path.join(configs_dir, "sam2.1", config_file)
183
+ config_path = configs_dir / "sam2.1" / config_file
99
184
  else:
100
- config_path = os.path.join(
101
- configs_dir, "sam2", config_file.replace("2.1_", "")
102
- )
185
+ config_path = configs_dir / "sam2" / config_file.replace("2.1_", "")
103
186
 
104
- if os.path.exists(config_path):
105
- return config_path
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 = os.path.join(configs_dir, "sam2.1", "sam2.1_hiera_l.yaml")
109
- if os.path.exists(fallback_config):
110
- return fallback_config
111
-
112
- raise FileNotFoundError(f"No suitable config found for {filename}")
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
- # Return a reasonable default path
117
- return "sam2.1_hiera_l.yaml"
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 = build_sam2(config_path, model_path, device=self.device)
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
@@ -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: 10px;
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: 11px;
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(260) # Slightly wider for better layout
206
- self.preferred_width = 280
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: 9px;
278
- min-width: 45px;
279
- max-width: 80px;
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: 9px;
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(75) # Fixed width for consistency
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: 10px;
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(75) # Fixed width for consistency
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: 10px;
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: 10px;
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: 10px;
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,
@@ -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):