lazylabel-gui 1.1.1__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.
Files changed (35) hide show
  1. lazylabel/__init__.py +1 -1
  2. lazylabel/config/__init__.py +3 -3
  3. lazylabel/config/hotkeys.py +96 -58
  4. lazylabel/config/paths.py +8 -9
  5. lazylabel/config/settings.py +15 -16
  6. lazylabel/core/__init__.py +3 -3
  7. lazylabel/core/file_manager.py +49 -33
  8. lazylabel/core/model_manager.py +9 -11
  9. lazylabel/core/segment_manager.py +21 -22
  10. lazylabel/main.py +1 -0
  11. lazylabel/models/__init__.py +1 -1
  12. lazylabel/models/sam_model.py +24 -19
  13. lazylabel/ui/__init__.py +3 -3
  14. lazylabel/ui/control_panel.py +21 -19
  15. lazylabel/ui/editable_vertex.py +16 -3
  16. lazylabel/ui/hotkey_dialog.py +125 -93
  17. lazylabel/ui/hoverable_polygon_item.py +1 -2
  18. lazylabel/ui/main_window.py +290 -49
  19. lazylabel/ui/photo_viewer.py +4 -7
  20. lazylabel/ui/reorderable_class_table.py +2 -3
  21. lazylabel/ui/right_panel.py +15 -16
  22. lazylabel/ui/widgets/__init__.py +1 -1
  23. lazylabel/ui/widgets/adjustments_widget.py +22 -21
  24. lazylabel/ui/widgets/model_selection_widget.py +28 -21
  25. lazylabel/ui/widgets/settings_widget.py +35 -28
  26. lazylabel/ui/widgets/status_bar.py +2 -2
  27. lazylabel/utils/__init__.py +2 -2
  28. lazylabel/utils/custom_file_system_model.py +3 -2
  29. {lazylabel_gui-1.1.1.dist-info → lazylabel_gui-1.1.3.dist-info}/METADATA +48 -2
  30. lazylabel_gui-1.1.3.dist-info/RECORD +37 -0
  31. lazylabel_gui-1.1.1.dist-info/RECORD +0 -37
  32. {lazylabel_gui-1.1.1.dist-info → lazylabel_gui-1.1.3.dist-info}/WHEEL +0 -0
  33. {lazylabel_gui-1.1.1.dist-info → lazylabel_gui-1.1.3.dist-info}/entry_points.txt +0 -0
  34. {lazylabel_gui-1.1.1.dist-info → lazylabel_gui-1.1.3.dist-info}/licenses/LICENSE +0 -0
  35. {lazylabel_gui-1.1.1.dist-info → lazylabel_gui-1.1.3.dist-info}/top_level.txt +0 -0
@@ -1,8 +1,9 @@
1
1
  """Segment management functionality."""
2
2
 
3
- import numpy as np
3
+ from typing import Any
4
+
4
5
  import cv2
5
- from typing import List, Dict, Any, Optional, Tuple
6
+ import numpy as np
6
7
  from PyQt6.QtCore import QPointF
7
8
 
8
9
 
@@ -10,10 +11,10 @@ class SegmentManager:
10
11
  """Manages image segments and classes."""
11
12
 
12
13
  def __init__(self):
13
- self.segments: List[Dict[str, Any]] = []
14
- self.class_aliases: Dict[int, str] = {}
14
+ self.segments: list[dict[str, Any]] = []
15
+ self.class_aliases: dict[int, str] = {}
15
16
  self.next_class_id: int = 0
16
- self.active_class_id: Optional[int] = None # Currently active/toggled class
17
+ self.active_class_id: int | None = None # Currently active/toggled class
17
18
 
18
19
  def clear(self) -> None:
19
20
  """Clear all segments and reset state."""
@@ -22,7 +23,7 @@ class SegmentManager:
22
23
  self.next_class_id = 0
23
24
  self.active_class_id = None
24
25
 
25
- def add_segment(self, segment_data: Dict[str, Any]) -> None:
26
+ def add_segment(self, segment_data: dict[str, Any]) -> None:
26
27
  """Add a new segment."""
27
28
  if "class_id" not in segment_data:
28
29
  # Use active class if available, otherwise use next class ID
@@ -33,14 +34,14 @@ class SegmentManager:
33
34
  self.segments.append(segment_data)
34
35
  self._update_next_class_id()
35
36
 
36
- def delete_segments(self, indices: List[int]) -> None:
37
+ def delete_segments(self, indices: list[int]) -> None:
37
38
  """Delete segments by indices."""
38
39
  for i in sorted(indices, reverse=True):
39
40
  if 0 <= i < len(self.segments):
40
41
  del self.segments[i]
41
42
  self._update_next_class_id()
42
43
 
43
- def assign_segments_to_class(self, indices: List[int]) -> None:
44
+ def assign_segments_to_class(self, indices: list[int]) -> None:
44
45
  """Assign selected segments to a class."""
45
46
  if not indices:
46
47
  return
@@ -62,21 +63,19 @@ class SegmentManager:
62
63
 
63
64
  self._update_next_class_id()
64
65
 
65
- def get_unique_class_ids(self) -> List[int]:
66
+ def get_unique_class_ids(self) -> list[int]:
66
67
  """Get sorted list of unique class IDs."""
67
68
  return sorted(
68
- list(
69
- {
70
- seg.get("class_id")
71
- for seg in self.segments
72
- if seg.get("class_id") is not None
73
- }
74
- )
69
+ {
70
+ seg.get("class_id")
71
+ for seg in self.segments
72
+ if seg.get("class_id") is not None
73
+ }
75
74
  )
76
75
 
77
76
  def rasterize_polygon(
78
- self, vertices: List[QPointF], image_size: Tuple[int, int]
79
- ) -> Optional[np.ndarray]:
77
+ self, vertices: list[QPointF], image_size: tuple[int, int]
78
+ ) -> np.ndarray | None:
80
79
  """Convert polygon vertices to binary mask."""
81
80
  if not vertices:
82
81
  return None
@@ -88,7 +87,7 @@ class SegmentManager:
88
87
  return mask.astype(bool)
89
88
 
90
89
  def create_final_mask_tensor(
91
- self, image_size: Tuple[int, int], class_order: List[int]
90
+ self, image_size: tuple[int, int], class_order: list[int]
92
91
  ) -> np.ndarray:
93
92
  """Create final mask tensor for saving."""
94
93
  h, w = image_size
@@ -115,7 +114,7 @@ class SegmentManager:
115
114
 
116
115
  return final_mask_tensor
117
116
 
118
- def reassign_class_ids(self, new_order: List[int]) -> None:
117
+ def reassign_class_ids(self, new_order: list[int]) -> None:
119
118
  """Reassign class IDs based on new order."""
120
119
  id_map = {old_id: new_id for new_id, old_id in enumerate(new_order)}
121
120
 
@@ -141,11 +140,11 @@ class SegmentManager:
141
140
  """Get alias for a class."""
142
141
  return self.class_aliases.get(class_id, str(class_id))
143
142
 
144
- def set_active_class(self, class_id: Optional[int]) -> None:
143
+ def set_active_class(self, class_id: int | None) -> None:
145
144
  """Set the active class ID."""
146
145
  self.active_class_id = class_id
147
146
 
148
- def get_active_class(self) -> Optional[int]:
147
+ def get_active_class(self) -> int | None:
149
148
  """Get the active class ID."""
150
149
  return self.active_class_id
151
150
 
lazylabel/main.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """Main entry point for LazyLabel application."""
2
2
 
3
3
  import sys
4
+
4
5
  import qdarktheme
5
6
  from PyQt6.QtWidgets import QApplication
6
7
 
@@ -2,4 +2,4 @@
2
2
 
3
3
  from .sam_model import SamModel
4
4
 
5
- __all__ = ['SamModel']
5
+ __all__ = ["SamModel"]
@@ -1,25 +1,26 @@
1
1
  import os
2
+
2
3
  import cv2
3
4
  import numpy as np
4
- import torch
5
5
  import requests
6
+ import torch
7
+ from segment_anything import SamPredictor, sam_model_registry
6
8
  from tqdm import tqdm
7
- from segment_anything import sam_model_registry, SamPredictor
8
9
 
9
10
 
10
11
  def download_model(url, download_path):
11
12
  """Downloads file with a progress bar."""
12
- print(f"[10/20] SAM model not found. Downloading from Meta's repository...")
13
+ print("[10/20] SAM model not found. Downloading from Meta's repository...")
13
14
  print(f" Downloading to: {download_path}")
14
15
  try:
15
- print(f"[10/20] Connecting to download server...")
16
+ print("[10/20] Connecting to download server...")
16
17
  response = requests.get(url, stream=True, timeout=30)
17
18
  response.raise_for_status()
18
19
  total_size_in_bytes = int(response.headers.get("content-length", 0))
19
20
  block_size = 1024 # 1 Kibibyte
20
21
 
21
22
  print(
22
- f"[10/20] Starting download ({total_size_in_bytes / (1024*1024*1024):.1f} GB)..."
23
+ f"[10/20] Starting download ({total_size_in_bytes / (1024 * 1024 * 1024):.1f} GB)..."
23
24
  )
24
25
  progress_bar = tqdm(total=total_size_in_bytes, unit="iB", unit_scale=True)
25
26
  with open(download_path, "wb") as file:
@@ -35,30 +36,34 @@ def download_model(url, download_path):
35
36
 
36
37
  except requests.exceptions.ConnectionError as e:
37
38
  raise RuntimeError(
38
- f"[10/20] Network connection failed: Check your internet connection"
39
- )
39
+ "[10/20] Network connection failed: Check your internet connection"
40
+ ) from e
40
41
  except requests.exceptions.Timeout as e:
41
- raise RuntimeError(f"[10/20] Download timeout: Server took too long to respond")
42
+ raise RuntimeError(
43
+ "[10/20] Download timeout: Server took too long to respond"
44
+ ) from e
42
45
  except requests.exceptions.HTTPError as e:
43
46
  raise RuntimeError(
44
47
  f"[10/20] HTTP error {e.response.status_code}: Server rejected request"
45
- )
48
+ ) from e
46
49
  except requests.exceptions.RequestException as e:
47
- raise RuntimeError(f"[10/20] Network error during download: {e}")
50
+ raise RuntimeError(f"[10/20] Network error during download: {e}") from e
48
51
  except PermissionError as e:
49
52
  raise RuntimeError(
50
53
  f"[10/20] Permission denied: Cannot write to {download_path}"
51
- )
54
+ ) from e
52
55
  except OSError as e:
53
- raise RuntimeError(f"[10/20] Disk error: {e} (check available disk space)")
56
+ raise RuntimeError(
57
+ f"[10/20] Disk error: {e} (check available disk space)"
58
+ ) from e
54
59
  except Exception as e:
55
60
  # Clean up partial download
56
61
  if os.path.exists(download_path):
57
- try:
62
+ import contextlib
63
+
64
+ with contextlib.suppress(OSError):
58
65
  os.remove(download_path)
59
- except:
60
- pass
61
- raise RuntimeError(f"[10/20] Download failed: {e}")
66
+ raise RuntimeError(f"[10/20] Download failed: {e}") from e
62
67
 
63
68
 
64
69
  class SamModel:
@@ -102,7 +107,7 @@ class SamModel:
102
107
 
103
108
  if os.path.exists(old_model_path) and not os.path.exists(model_path):
104
109
  print(
105
- f"[10/20] Moving existing model from cache to models folder..."
110
+ "[10/20] Moving existing model from cache to models folder..."
106
111
  )
107
112
  import shutil
108
113
 
@@ -118,14 +123,14 @@ class SamModel:
118
123
  self.device
119
124
  )
120
125
 
121
- print(f"[12/20] Setting up predictor...")
126
+ print("[12/20] Setting up predictor...")
122
127
  self.predictor = SamPredictor(self.model)
123
128
  self.is_loaded = True
124
129
  print("[13/20] SAM model loaded successfully.")
125
130
 
126
131
  except Exception as e:
127
132
  print(f"[8/20] Failed to load SAM model: {e}")
128
- print(f"[8/20] SAM point functionality will be disabled.")
133
+ print("[8/20] SAM point functionality will be disabled.")
129
134
  self.is_loaded = False
130
135
 
131
136
  def load_custom_model(self, model_path, model_type="vit_h"):
lazylabel/ui/__init__.py CHANGED
@@ -1,8 +1,8 @@
1
1
  """UI components for LazyLabel."""
2
2
 
3
- from .main_window import MainWindow
4
3
  from .control_panel import ControlPanel
5
- from .right_panel import RightPanel
6
4
  from .hotkey_dialog import HotkeyDialog
5
+ from .main_window import MainWindow
6
+ from .right_panel import RightPanel
7
7
 
8
- __all__ = ['MainWindow', 'ControlPanel', 'RightPanel', 'HotkeyDialog']
8
+ __all__ = ["MainWindow", "ControlPanel", "RightPanel", "HotkeyDialog"]
@@ -1,20 +1,16 @@
1
1
  """Left control panel with mode controls and settings."""
2
2
 
3
+ from PyQt6.QtCore import Qt, pyqtSignal
3
4
  from PyQt6.QtWidgets import (
4
- QWidget,
5
- QVBoxLayout,
6
- QPushButton,
7
- QLabel,
8
5
  QFrame,
9
6
  QHBoxLayout,
10
- QCheckBox,
11
- QSlider,
12
- QGroupBox,
13
- QComboBox,
7
+ QLabel,
8
+ QPushButton,
9
+ QVBoxLayout,
10
+ QWidget,
14
11
  )
15
- from PyQt6.QtCore import Qt, pyqtSignal
16
12
 
17
- from .widgets import ModelSelectionWidget, SettingsWidget, AdjustmentsWidget
13
+ from .widgets import AdjustmentsWidget, ModelSelectionWidget, SettingsWidget
18
14
 
19
15
 
20
16
  class ControlPanel(QWidget):
@@ -159,20 +155,12 @@ class ControlPanel(QWidget):
159
155
  self.btn_hotkeys.clicked.connect(self.hotkeys_requested)
160
156
  self.btn_popout.clicked.connect(self.pop_out_requested)
161
157
 
162
- def mouseDoubleClickEvent(self, event):
163
- """Handle double-click to expand collapsed panel."""
164
- if self.width() < 50: # If panel is collapsed
165
- # Request expansion by calling parent method
166
- if self.parent() and hasattr(self.parent(), "_expand_left_panel"):
167
- self.parent()._expand_left_panel()
168
- super().mouseDoubleClickEvent(event)
169
-
170
158
  # Model widget signals
171
159
  self.model_widget.browse_requested.connect(self.browse_models_requested)
172
160
  self.model_widget.refresh_requested.connect(self.refresh_models_requested)
173
161
  self.model_widget.model_selected.connect(self.model_selected)
174
162
 
175
- # Settings widget signals
163
+ # Adjustments widget signals
176
164
  self.adjustments_widget.annotation_size_changed.connect(
177
165
  self.annotation_size_changed
178
166
  )
@@ -181,6 +169,16 @@ class ControlPanel(QWidget):
181
169
  self.join_threshold_changed
182
170
  )
183
171
 
172
+ def mouseDoubleClickEvent(self, event):
173
+ """Handle double-click to expand collapsed panel."""
174
+ if (
175
+ self.width() < 50
176
+ and self.parent()
177
+ and hasattr(self.parent(), "_expand_left_panel")
178
+ ):
179
+ self.parent()._expand_left_panel()
180
+ super().mouseDoubleClickEvent(event)
181
+
184
182
  def show_notification(self, message: str, duration: int = 3000):
185
183
  """Show a notification message."""
186
184
  self.notification_label.setText(message)
@@ -219,6 +217,10 @@ class ControlPanel(QWidget):
219
217
  """Set annotation size."""
220
218
  self.adjustments_widget.set_annotation_size(value)
221
219
 
220
+ def set_join_threshold(self, value):
221
+ """Set join threshold."""
222
+ self.adjustments_widget.set_join_threshold(value)
223
+
222
224
  def set_sam_mode_enabled(self, enabled: bool):
223
225
  """Enable or disable the SAM mode button."""
224
226
  self.btn_sam_mode.setEnabled(enabled)
@@ -1,6 +1,6 @@
1
- from PyQt6.QtWidgets import QGraphicsEllipseItem, QGraphicsItem
2
1
  from PyQt6.QtCore import Qt
3
- from PyQt6.QtGui import QBrush, QPen, QColor
2
+ from PyQt6.QtGui import QBrush, QColor, QPen
3
+ from PyQt6.QtWidgets import QGraphicsEllipseItem, QGraphicsItem
4
4
 
5
5
 
6
6
  class EditableVertexItem(QGraphicsEllipseItem):
@@ -9,6 +9,7 @@ class EditableVertexItem(QGraphicsEllipseItem):
9
9
  self.main_window = main_window
10
10
  self.segment_index = segment_index
11
11
  self.vertex_index = vertex_index
12
+ self.initial_pos = None
12
13
 
13
14
  self.setZValue(200)
14
15
 
@@ -31,12 +32,13 @@ class EditableVertexItem(QGraphicsEllipseItem):
31
32
  new_pos = value
32
33
  if hasattr(self.main_window, "update_vertex_pos"):
33
34
  self.main_window.update_vertex_pos(
34
- self.segment_index, self.vertex_index, new_pos
35
+ self.segment_index, self.vertex_index, new_pos, record_undo=False
35
36
  )
36
37
  return super().itemChange(change, value)
37
38
 
38
39
  def mousePressEvent(self, event):
39
40
  """Handle mouse press events."""
41
+ self.initial_pos = self.pos()
40
42
  super().mousePressEvent(event)
41
43
  event.accept()
42
44
 
@@ -47,5 +49,16 @@ class EditableVertexItem(QGraphicsEllipseItem):
47
49
 
48
50
  def mouseReleaseEvent(self, event):
49
51
  """Handle mouse release events."""
52
+ if self.initial_pos and self.initial_pos != self.pos():
53
+ self.main_window.action_history.append(
54
+ {
55
+ "type": "move_vertex",
56
+ "segment_index": self.segment_index,
57
+ "vertex_index": self.vertex_index,
58
+ "old_pos": self.initial_pos,
59
+ "new_pos": self.pos(),
60
+ }
61
+ )
62
+ self.initial_pos = None
50
63
  super().mouseReleaseEvent(event)
51
64
  event.accept()