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.
- lazylabel/__init__.py +1 -1
- lazylabel/config/__init__.py +3 -3
- lazylabel/config/hotkeys.py +96 -58
- lazylabel/config/paths.py +8 -9
- lazylabel/config/settings.py +15 -16
- lazylabel/core/__init__.py +3 -3
- lazylabel/core/file_manager.py +49 -33
- lazylabel/core/model_manager.py +9 -11
- lazylabel/core/segment_manager.py +21 -22
- lazylabel/main.py +1 -0
- lazylabel/models/__init__.py +1 -1
- lazylabel/models/sam_model.py +24 -19
- lazylabel/ui/__init__.py +3 -3
- lazylabel/ui/control_panel.py +21 -19
- lazylabel/ui/editable_vertex.py +16 -3
- lazylabel/ui/hotkey_dialog.py +125 -93
- lazylabel/ui/hoverable_polygon_item.py +1 -2
- lazylabel/ui/main_window.py +290 -49
- lazylabel/ui/photo_viewer.py +4 -7
- lazylabel/ui/reorderable_class_table.py +2 -3
- lazylabel/ui/right_panel.py +15 -16
- lazylabel/ui/widgets/__init__.py +1 -1
- lazylabel/ui/widgets/adjustments_widget.py +22 -21
- lazylabel/ui/widgets/model_selection_widget.py +28 -21
- lazylabel/ui/widgets/settings_widget.py +35 -28
- lazylabel/ui/widgets/status_bar.py +2 -2
- lazylabel/utils/__init__.py +2 -2
- lazylabel/utils/custom_file_system_model.py +3 -2
- {lazylabel_gui-1.1.1.dist-info → lazylabel_gui-1.1.3.dist-info}/METADATA +48 -2
- lazylabel_gui-1.1.3.dist-info/RECORD +37 -0
- lazylabel_gui-1.1.1.dist-info/RECORD +0 -37
- {lazylabel_gui-1.1.1.dist-info → lazylabel_gui-1.1.3.dist-info}/WHEEL +0 -0
- {lazylabel_gui-1.1.1.dist-info → lazylabel_gui-1.1.3.dist-info}/entry_points.txt +0 -0
- {lazylabel_gui-1.1.1.dist-info → lazylabel_gui-1.1.3.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
3
|
+
from typing import Any
|
4
|
+
|
4
5
|
import cv2
|
5
|
-
|
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:
|
14
|
-
self.class_aliases:
|
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:
|
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:
|
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:
|
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:
|
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) ->
|
66
|
+
def get_unique_class_ids(self) -> list[int]:
|
66
67
|
"""Get sorted list of unique class IDs."""
|
67
68
|
return sorted(
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
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:
|
79
|
-
) ->
|
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:
|
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:
|
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:
|
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) ->
|
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
lazylabel/models/__init__.py
CHANGED
lazylabel/models/sam_model.py
CHANGED
@@ -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(
|
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(
|
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
|
-
|
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(
|
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(
|
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
|
-
|
62
|
+
import contextlib
|
63
|
+
|
64
|
+
with contextlib.suppress(OSError):
|
58
65
|
os.remove(download_path)
|
59
|
-
|
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
|
-
|
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(
|
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(
|
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__ = [
|
8
|
+
__all__ = ["MainWindow", "ControlPanel", "RightPanel", "HotkeyDialog"]
|
lazylabel/ui/control_panel.py
CHANGED
@@ -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
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
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
|
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
|
-
#
|
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)
|
lazylabel/ui/editable_vertex.py
CHANGED
@@ -1,6 +1,6 @@
|
|
1
|
-
from PyQt6.QtWidgets import QGraphicsEllipseItem, QGraphicsItem
|
2
1
|
from PyQt6.QtCore import Qt
|
3
|
-
from PyQt6.QtGui import QBrush,
|
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()
|