lazylabel-gui 1.0.5__tar.gz → 1.0.7__tar.gz
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_gui-1.0.5 → lazylabel_gui-1.0.7}/PKG-INFO +2 -2
- {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/README.md +1 -1
- {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/pyproject.toml +1 -1
- {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel/controls.py +59 -4
- {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel/custom_file_system_model.py +10 -4
- {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel/editable_vertex.py +7 -3
- lazylabel_gui-1.0.7/src/lazylabel/hoverable_pixelmap_item.py +22 -0
- {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel/main.py +387 -80
- {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel/photo_viewer.py +6 -3
- {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel/reorderable_class_table.py +10 -7
- {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel/utils.py +2 -2
- {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel_gui.egg-info/PKG-INFO +2 -2
- {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel_gui.egg-info/SOURCES.txt +1 -0
- {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/LICENSE +0 -0
- {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/setup.cfg +0 -0
- {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel/hoverable_polygon_item.py +0 -0
- {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel/numeric_table_widget_item.py +0 -0
- {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel/sam_model.py +0 -0
- {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel_gui.egg-info/dependency_links.txt +0 -0
- {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel_gui.egg-info/entry_points.txt +0 -0
- {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel_gui.egg-info/requires.txt +0 -0
- {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel_gui.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: lazylabel-gui
|
3
|
-
Version: 1.0.
|
3
|
+
Version: 1.0.7
|
4
4
|
Summary: An image segmentation GUI for generating mask tensors.
|
5
5
|
Author-email: "Deniz N. Cakan" <deniz.n.cakan@gmail.com>
|
6
6
|
License: MIT License
|
@@ -48,7 +48,7 @@ Requires-Dist: tqdm>=4.67.1
|
|
48
48
|
Dynamic: license-file
|
49
49
|
|
50
50
|
# <img src="https://raw.githubusercontent.com/dnzckn/LazyLabel/main/src/lazylabel/demo_pictures/logo2.png" alt="LazyLabel Logo" style="height:60px; vertical-align:middle;" /> <img src="https://raw.githubusercontent.com/dnzckn/LazyLabel/main/src/lazylabel/demo_pictures/logo_black.png" alt="LazyLabel Cursive" style="height:60px; vertical-align:middle;" />
|
51
|
-
LazyLabel is an intuitive, AI-assisted image segmentation tool. It uses Meta's Segment Anything Model (SAM) for quick, precise mask generation, alongside advanced polygon editing for fine-tuned control. Outputs are saved in a clean, one-hot encoded format for easy machine learning integration.
|
51
|
+
LazyLabel is an intuitive, AI-assisted image segmentation tool. It uses Meta's Segment Anything Model (SAM) for quick, precise mask generation, alongside advanced polygon editing for fine-tuned control. Outputs are saved in a clean, one-hot encoded `.npz` format for easy machine learning integration and in YOLO `.txt` format.
|
52
52
|
|
53
53
|
Inspired by [LabelMe](https://github.com/wkentaro/labelme?tab=readme-ov-file#installation) and [Segment-Anything-UI](https://github.com/branislavhesko/segment-anything-ui/tree/main).
|
54
54
|
|
@@ -1,5 +1,5 @@
|
|
1
1
|
# <img src="https://raw.githubusercontent.com/dnzckn/LazyLabel/main/src/lazylabel/demo_pictures/logo2.png" alt="LazyLabel Logo" style="height:60px; vertical-align:middle;" /> <img src="https://raw.githubusercontent.com/dnzckn/LazyLabel/main/src/lazylabel/demo_pictures/logo_black.png" alt="LazyLabel Cursive" style="height:60px; vertical-align:middle;" />
|
2
|
-
LazyLabel is an intuitive, AI-assisted image segmentation tool. It uses Meta's Segment Anything Model (SAM) for quick, precise mask generation, alongside advanced polygon editing for fine-tuned control. Outputs are saved in a clean, one-hot encoded format for easy machine learning integration.
|
2
|
+
LazyLabel is an intuitive, AI-assisted image segmentation tool. It uses Meta's Segment Anything Model (SAM) for quick, precise mask generation, alongside advanced polygon editing for fine-tuned control. Outputs are saved in a clean, one-hot encoded `.npz` format for easy machine learning integration and in YOLO `.txt` format.
|
3
3
|
|
4
4
|
Inspired by [LabelMe](https://github.com/wkentaro/labelme?tab=readme-ov-file#installation) and [Segment-Anything-UI](https://github.com/branislavhesko/segment-anything-ui/tree/main).
|
5
5
|
|
@@ -10,6 +10,7 @@ from PyQt6.QtWidgets import (
|
|
10
10
|
QHBoxLayout,
|
11
11
|
QComboBox,
|
12
12
|
QHeaderView,
|
13
|
+
QCheckBox,
|
13
14
|
)
|
14
15
|
from PyQt6.QtCore import Qt
|
15
16
|
from .reorderable_class_table import ReorderableClassTable
|
@@ -26,20 +27,56 @@ class ControlPanel(QWidget):
|
|
26
27
|
font.setBold(True)
|
27
28
|
self.mode_label.setFont(font)
|
28
29
|
layout.addWidget(self.mode_label)
|
30
|
+
|
31
|
+
# Mode Buttons
|
29
32
|
self.btn_sam_mode = QPushButton("Point Mode (1)")
|
33
|
+
self.btn_sam_mode.setToolTip("Switch to Point Mode for AI segmentation (1)")
|
30
34
|
self.btn_polygon_mode = QPushButton("Polygon Mode (2)")
|
35
|
+
self.btn_polygon_mode.setToolTip("Switch to Polygon Drawing Mode (2)")
|
31
36
|
self.btn_selection_mode = QPushButton("Selection Mode (E)")
|
37
|
+
self.btn_selection_mode.setToolTip("Toggle segment selection (E)")
|
32
38
|
layout.addWidget(self.btn_sam_mode)
|
33
39
|
layout.addWidget(self.btn_polygon_mode)
|
34
40
|
layout.addWidget(self.btn_selection_mode)
|
41
|
+
|
35
42
|
layout.addSpacing(20)
|
36
43
|
line1 = QFrame()
|
37
44
|
line1.setFrameShape(QFrame.Shape.HLine)
|
38
45
|
layout.addWidget(line1)
|
39
46
|
layout.addSpacing(10)
|
47
|
+
|
48
|
+
# Action Buttons
|
49
|
+
self.btn_fit_view = QPushButton("Fit View (.)")
|
50
|
+
self.btn_fit_view.setToolTip("Reset image zoom and pan to fit the view (.)")
|
40
51
|
self.btn_clear_points = QPushButton("Clear Clicks (C)")
|
52
|
+
self.btn_clear_points.setToolTip("Clear current temporary points/vertices (C)")
|
53
|
+
layout.addWidget(self.btn_fit_view)
|
41
54
|
layout.addWidget(self.btn_clear_points)
|
55
|
+
|
56
|
+
layout.addSpacing(10)
|
57
|
+
|
58
|
+
# Settings
|
59
|
+
self.chk_auto_save = QCheckBox("Auto-Save on Navigate")
|
60
|
+
self.chk_auto_save.setToolTip(
|
61
|
+
"Automatically save work when using arrow keys to change images."
|
62
|
+
)
|
63
|
+
self.chk_auto_save.setChecked(True)
|
64
|
+
layout.addWidget(self.chk_auto_save)
|
65
|
+
|
42
66
|
layout.addStretch()
|
67
|
+
|
68
|
+
# Notification Label
|
69
|
+
self.notification_label = QLabel("")
|
70
|
+
font = self.notification_label.font()
|
71
|
+
font.setItalic(True)
|
72
|
+
self.notification_label.setFont(font)
|
73
|
+
self.notification_label.setStyleSheet(
|
74
|
+
"color: #ffa500;"
|
75
|
+
) # Orange color for visibility
|
76
|
+
self.notification_label.setWordWrap(True)
|
77
|
+
layout.addWidget(self.notification_label)
|
78
|
+
|
79
|
+
# Device Label
|
43
80
|
self.device_label = QLabel("Device: Unknown")
|
44
81
|
layout.addWidget(self.device_label)
|
45
82
|
self.setFixedWidth(250)
|
@@ -53,6 +90,7 @@ class RightPanel(QWidget):
|
|
53
90
|
# File Explorer
|
54
91
|
file_explorer_layout = QVBoxLayout()
|
55
92
|
self.btn_open_folder = QPushButton("Open Image Folder")
|
93
|
+
self.btn_open_folder.setToolTip("Open a directory of images")
|
56
94
|
self.file_tree = QTreeView()
|
57
95
|
file_explorer_layout.addWidget(self.btn_open_folder)
|
58
96
|
file_explorer_layout.addWidget(self.file_tree)
|
@@ -68,12 +106,13 @@ class RightPanel(QWidget):
|
|
68
106
|
class_filter_layout = QHBoxLayout()
|
69
107
|
class_filter_layout.addWidget(QLabel("Filter Class:"))
|
70
108
|
self.class_filter_combo = QComboBox()
|
109
|
+
self.class_filter_combo.setToolTip("Filter segments list by class")
|
71
110
|
class_filter_layout.addWidget(self.class_filter_combo)
|
72
111
|
segment_layout.addLayout(class_filter_layout)
|
73
112
|
|
74
113
|
self.segment_table = QTableWidget()
|
75
114
|
self.segment_table.setColumnCount(3)
|
76
|
-
self.segment_table.setHorizontalHeaderLabels(["Index", "Class", "Type"])
|
115
|
+
self.segment_table.setHorizontalHeaderLabels(["Index", "Class ID", "Type"])
|
77
116
|
self.segment_table.horizontalHeader().setSectionResizeMode(
|
78
117
|
QHeaderView.ResizeMode.Stretch
|
79
118
|
)
|
@@ -85,7 +124,13 @@ class RightPanel(QWidget):
|
|
85
124
|
|
86
125
|
segment_action_layout = QHBoxLayout()
|
87
126
|
self.btn_merge_selection = QPushButton("Merge to Class")
|
127
|
+
self.btn_merge_selection.setToolTip(
|
128
|
+
"Merge selected segments into a single class (M)"
|
129
|
+
)
|
88
130
|
self.btn_delete_selection = QPushButton("Delete")
|
131
|
+
self.btn_delete_selection.setToolTip(
|
132
|
+
"Delete selected segments (Delete/Backspace)"
|
133
|
+
)
|
89
134
|
segment_action_layout.addWidget(self.btn_merge_selection)
|
90
135
|
segment_action_layout.addWidget(self.btn_delete_selection)
|
91
136
|
segment_layout.addLayout(segment_action_layout)
|
@@ -95,13 +140,23 @@ class RightPanel(QWidget):
|
|
95
140
|
class_layout = QVBoxLayout()
|
96
141
|
class_layout.addWidget(QLabel("Class Order:"))
|
97
142
|
self.class_table = ReorderableClassTable()
|
98
|
-
self.class_table.
|
99
|
-
|
143
|
+
self.class_table.setToolTip(
|
144
|
+
"Set class aliases and drag to reorder channels for saving."
|
145
|
+
)
|
146
|
+
self.class_table.setColumnCount(2)
|
147
|
+
self.class_table.setHorizontalHeaderLabels(["Alias", "Channel Index"])
|
100
148
|
self.class_table.horizontalHeader().setSectionResizeMode(
|
101
|
-
QHeaderView.ResizeMode.Stretch
|
149
|
+
0, QHeaderView.ResizeMode.Stretch
|
102
150
|
)
|
151
|
+
self.class_table.horizontalHeader().setSectionResizeMode(
|
152
|
+
1, QHeaderView.ResizeMode.ResizeToContents
|
153
|
+
)
|
154
|
+
|
103
155
|
class_layout.addWidget(self.class_table)
|
104
156
|
self.btn_reassign_classes = QPushButton("Reassign Class IDs")
|
157
|
+
self.btn_reassign_classes.setToolTip(
|
158
|
+
"Re-index class channels based on the current order in this table"
|
159
|
+
)
|
105
160
|
class_layout.addWidget(self.btn_reassign_classes)
|
106
161
|
layout.addLayout(class_layout, 1)
|
107
162
|
|
@@ -12,7 +12,9 @@ class CustomFileSystemModel(QFileSystemModel):
|
|
12
12
|
self.highlighted_path = None
|
13
13
|
|
14
14
|
def set_highlighted_path(self, path):
|
15
|
-
self.highlighted_path = path
|
15
|
+
self.highlighted_path = os.path.normpath(path) if path else None
|
16
|
+
# Trigger repaint of the entire view
|
17
|
+
self.layoutChanged.emit()
|
16
18
|
|
17
19
|
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
|
18
20
|
return 2
|
@@ -39,9 +41,13 @@ class CustomFileSystemModel(QFileSystemModel):
|
|
39
41
|
|
40
42
|
# Handle the temporary highlight for saving
|
41
43
|
if role == Qt.ItemDataRole.BackgroundRole:
|
42
|
-
filePath = self.filePath(index)
|
43
|
-
if
|
44
|
-
|
44
|
+
filePath = os.path.normpath(self.filePath(index))
|
45
|
+
if (
|
46
|
+
self.highlighted_path
|
47
|
+
and os.path.splitext(filePath)[0]
|
48
|
+
== os.path.splitext(self.highlighted_path)[0]
|
49
|
+
):
|
50
|
+
return QBrush(QColor(40, 80, 40)) # Dark green highlight
|
45
51
|
|
46
52
|
if index.column() == 1:
|
47
53
|
if role == Qt.ItemDataRole.CheckStateRole:
|
@@ -1,6 +1,6 @@
|
|
1
1
|
from PyQt6.QtWidgets import QGraphicsEllipseItem, QGraphicsItem
|
2
2
|
from PyQt6.QtCore import Qt
|
3
|
-
from PyQt6.QtGui import QBrush, QPen
|
3
|
+
from PyQt6.QtGui import QBrush, QPen, QColor
|
4
4
|
|
5
5
|
|
6
6
|
class EditableVertexItem(QGraphicsEllipseItem):
|
@@ -11,8 +11,12 @@ class EditableVertexItem(QGraphicsEllipseItem):
|
|
11
11
|
self.vertex_index = vertex_index
|
12
12
|
|
13
13
|
self.setZValue(200)
|
14
|
-
|
15
|
-
|
14
|
+
|
15
|
+
color = QColor(Qt.GlobalColor.cyan)
|
16
|
+
color.setAlpha(180)
|
17
|
+
self.setBrush(QBrush(color))
|
18
|
+
|
19
|
+
self.setPen(QPen(Qt.GlobalColor.transparent))
|
16
20
|
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
|
17
21
|
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
|
18
22
|
|
@@ -0,0 +1,22 @@
|
|
1
|
+
from PyQt6.QtWidgets import QGraphicsPixmapItem
|
2
|
+
|
3
|
+
|
4
|
+
class HoverablePixmapItem(QGraphicsPixmapItem):
|
5
|
+
def __init__(self, parent=None):
|
6
|
+
super().__init__(parent)
|
7
|
+
self.setAcceptHoverEvents(True)
|
8
|
+
self.default_pixmap = None
|
9
|
+
self.hover_pixmap = None
|
10
|
+
|
11
|
+
def set_pixmaps(self, default_pixmap, hover_pixmap):
|
12
|
+
self.default_pixmap = default_pixmap
|
13
|
+
self.hover_pixmap = hover_pixmap
|
14
|
+
self.setPixmap(self.default_pixmap)
|
15
|
+
|
16
|
+
def hoverEnterEvent(self, event):
|
17
|
+
self.setPixmap(self.hover_pixmap)
|
18
|
+
super().hoverEnterEvent(event)
|
19
|
+
|
20
|
+
def hoverLeaveEvent(self, event):
|
21
|
+
self.setPixmap(self.default_pixmap)
|
22
|
+
super().hoverLeaveEvent(event)
|
@@ -16,8 +16,18 @@ from PyQt6.QtWidgets import (
|
|
16
16
|
QGraphicsPolygonItem,
|
17
17
|
QTableWidgetSelectionRange,
|
18
18
|
)
|
19
|
-
from PyQt6.QtGui import
|
20
|
-
|
19
|
+
from PyQt6.QtGui import (
|
20
|
+
QPixmap,
|
21
|
+
QColor,
|
22
|
+
QPen,
|
23
|
+
QBrush,
|
24
|
+
QPolygonF,
|
25
|
+
QIcon,
|
26
|
+
QCursor,
|
27
|
+
QKeySequence,
|
28
|
+
QShortcut,
|
29
|
+
)
|
30
|
+
from PyQt6.QtCore import Qt, QPointF, QTimer, QModelIndex
|
21
31
|
|
22
32
|
from .photo_viewer import PhotoViewer
|
23
33
|
from .sam_model import SamModel
|
@@ -26,6 +36,7 @@ from .controls import ControlPanel, RightPanel
|
|
26
36
|
from .custom_file_system_model import CustomFileSystemModel
|
27
37
|
from .editable_vertex import EditableVertexItem
|
28
38
|
from .hoverable_polygon_item import HoverablePolygonItem
|
39
|
+
from .hoverable_pixelmap_item import HoverablePixmapItem
|
29
40
|
from .numeric_table_widget_item import NumericTableWidgetItem
|
30
41
|
|
31
42
|
|
@@ -46,9 +57,18 @@ class MainWindow(QMainWindow):
|
|
46
57
|
self.mode = "sam_points"
|
47
58
|
self.previous_mode = "sam_points"
|
48
59
|
self.current_image_path = None
|
49
|
-
self.current_file_index =
|
60
|
+
self.current_file_index = QModelIndex()
|
61
|
+
|
50
62
|
self.next_class_id = 0
|
51
63
|
|
64
|
+
self.class_aliases = {} # {class_id: "alias_string"}
|
65
|
+
|
66
|
+
self.point_radius = 0.3
|
67
|
+
self.line_thickness = 0.5
|
68
|
+
|
69
|
+
self._original_point_radius = self.point_radius
|
70
|
+
self._original_line_thickness = self.line_thickness
|
71
|
+
|
52
72
|
self.point_items, self.positive_points, self.negative_points = [], [], []
|
53
73
|
self.polygon_points, self.polygon_preview_items = [], []
|
54
74
|
self.rubber_band_line = None
|
@@ -103,6 +123,7 @@ class MainWindow(QMainWindow):
|
|
103
123
|
self.highlight_selected_segments
|
104
124
|
)
|
105
125
|
self.right_panel.segment_table.itemChanged.connect(self.handle_class_id_change)
|
126
|
+
self.right_panel.class_table.itemChanged.connect(self.handle_alias_change)
|
106
127
|
self.right_panel.btn_reassign_classes.clicked.connect(self.reassign_class_ids)
|
107
128
|
self.right_panel.class_filter_combo.currentIndexChanged.connect(
|
108
129
|
self.update_segment_table
|
@@ -114,13 +135,26 @@ class MainWindow(QMainWindow):
|
|
114
135
|
self.toggle_selection_mode
|
115
136
|
)
|
116
137
|
self.control_panel.btn_clear_points.clicked.connect(self.clear_all_points)
|
138
|
+
self.control_panel.btn_fit_view.clicked.connect(self.viewer.fitInView)
|
139
|
+
|
140
|
+
# **FIX:** Use QShortcut for reliable global hotkeys
|
141
|
+
next_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Right), self)
|
142
|
+
next_shortcut.activated.connect(self.load_next_image)
|
143
|
+
prev_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Left), self)
|
144
|
+
prev_shortcut.activated.connect(self.load_previous_image)
|
145
|
+
|
146
|
+
def show_notification(self, message, duration=3000):
|
147
|
+
self.control_panel.notification_label.setText(message)
|
148
|
+
QTimer.singleShot(
|
149
|
+
duration, lambda: self.control_panel.notification_label.clear()
|
150
|
+
)
|
117
151
|
|
118
|
-
def _get_color_for_class(self, class_id
|
152
|
+
def _get_color_for_class(self, class_id):
|
119
153
|
if class_id is None:
|
120
154
|
return QColor.fromHsv(0, 0, 128)
|
121
155
|
|
122
156
|
hue = int((class_id * 222.4922359) % 360)
|
123
|
-
color = QColor.fromHsv(hue,
|
157
|
+
color = QColor.fromHsv(hue, 220, 220)
|
124
158
|
|
125
159
|
if not color.isValid():
|
126
160
|
return QColor(Qt.GlobalColor.white)
|
@@ -140,6 +174,16 @@ class MainWindow(QMainWindow):
|
|
140
174
|
f"Mode: {mode_name.replace('_', ' ').title()}"
|
141
175
|
)
|
142
176
|
self.clear_all_points()
|
177
|
+
|
178
|
+
cursor_map = {
|
179
|
+
"sam_points": Qt.CursorShape.CrossCursor,
|
180
|
+
"polygon": Qt.CursorShape.CrossCursor,
|
181
|
+
"selection": Qt.CursorShape.ArrowCursor,
|
182
|
+
"edit": Qt.CursorShape.SizeAllCursor,
|
183
|
+
"pan": Qt.CursorShape.OpenHandCursor,
|
184
|
+
}
|
185
|
+
self.viewer.set_cursor(cursor_map.get(self.mode, Qt.CursorShape.ArrowCursor))
|
186
|
+
|
143
187
|
self.viewer.setDragMode(
|
144
188
|
self.viewer.DragMode.ScrollHandDrag
|
145
189
|
if self.mode == "pan"
|
@@ -168,14 +212,25 @@ class MainWindow(QMainWindow):
|
|
168
212
|
|
169
213
|
def toggle_edit_mode(self):
|
170
214
|
selected_indices = self.get_selected_segment_indices()
|
215
|
+
|
216
|
+
if self.mode == "edit":
|
217
|
+
self.set_mode("selection", is_toggle=True)
|
218
|
+
return
|
219
|
+
|
220
|
+
if not selected_indices:
|
221
|
+
self.show_notification("Select a polygon to edit.")
|
222
|
+
return
|
223
|
+
|
171
224
|
can_edit = any(
|
172
225
|
self.segments[i].get("type") == "Polygon" for i in selected_indices
|
173
226
|
)
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
227
|
+
|
228
|
+
if not can_edit:
|
229
|
+
self.show_notification("Only polygon segments can be edited.")
|
230
|
+
return
|
231
|
+
|
232
|
+
self.set_mode("edit", is_toggle=True)
|
233
|
+
self.display_all_segments()
|
179
234
|
|
180
235
|
def open_folder_dialog(self):
|
181
236
|
folder_path = QFileDialog.getExistingDirectory(self, "Select Image Folder")
|
@@ -186,7 +241,7 @@ class MainWindow(QMainWindow):
|
|
186
241
|
self.viewer.setFocus()
|
187
242
|
|
188
243
|
def load_selected_image(self, index):
|
189
|
-
if not index.isValid():
|
244
|
+
if not index.isValid() or not self.file_model.isDir(index.parent()):
|
190
245
|
return
|
191
246
|
|
192
247
|
self.current_file_index = index
|
@@ -202,10 +257,39 @@ class MainWindow(QMainWindow):
|
|
202
257
|
self.viewer.set_photo(pixmap)
|
203
258
|
self.sam_model.set_image(self.current_image_path)
|
204
259
|
self.load_existing_mask()
|
260
|
+
self.right_panel.file_tree.setCurrentIndex(index)
|
205
261
|
self.viewer.setFocus()
|
206
262
|
|
263
|
+
def load_next_image(self):
|
264
|
+
if not self.current_file_index.isValid():
|
265
|
+
return
|
266
|
+
|
267
|
+
if self.control_panel.chk_auto_save.isChecked():
|
268
|
+
self.save_output_to_npz()
|
269
|
+
|
270
|
+
row = self.current_file_index.row()
|
271
|
+
parent = self.current_file_index.parent()
|
272
|
+
if row + 1 < self.file_model.rowCount(parent):
|
273
|
+
next_index = self.file_model.index(row + 1, 0, parent)
|
274
|
+
self.load_selected_image(next_index)
|
275
|
+
|
276
|
+
def load_previous_image(self):
|
277
|
+
if not self.current_file_index.isValid():
|
278
|
+
return
|
279
|
+
|
280
|
+
if self.control_panel.chk_auto_save.isChecked():
|
281
|
+
self.save_output_to_npz()
|
282
|
+
|
283
|
+
row = self.current_file_index.row()
|
284
|
+
parent = self.current_file_index.parent()
|
285
|
+
if row > 0:
|
286
|
+
prev_index = self.file_model.index(row - 1, 0, parent)
|
287
|
+
self.load_selected_image(prev_index)
|
288
|
+
|
207
289
|
def reset_state(self):
|
208
290
|
self.clear_all_points()
|
291
|
+
# Preserve aliases between images in the same session
|
292
|
+
# self.class_aliases.clear()
|
209
293
|
self.segments.clear()
|
210
294
|
self.next_class_id = 0
|
211
295
|
self.update_all_lists()
|
@@ -223,26 +307,32 @@ class MainWindow(QMainWindow):
|
|
223
307
|
key, mods = event.key(), event.modifiers()
|
224
308
|
if event.isAutoRepeat():
|
225
309
|
return
|
310
|
+
|
311
|
+
pan_multiplier = 5.0 if (mods & Qt.KeyboardModifier.ShiftModifier) else 2
|
312
|
+
|
226
313
|
if key == Qt.Key.Key_W:
|
314
|
+
amount = int(self.viewer.height() * 0.1 * pan_multiplier)
|
227
315
|
self.viewer.verticalScrollBar().setValue(
|
228
|
-
self.viewer.verticalScrollBar().value()
|
229
|
-
- int(self.viewer.height() * 0.1)
|
316
|
+
self.viewer.verticalScrollBar().value() - amount
|
230
317
|
)
|
231
|
-
elif key == Qt.Key.Key_S
|
318
|
+
elif key == Qt.Key.Key_S:
|
319
|
+
amount = int(self.viewer.height() * 0.1 * pan_multiplier)
|
232
320
|
self.viewer.verticalScrollBar().setValue(
|
233
|
-
self.viewer.verticalScrollBar().value()
|
234
|
-
+ int(self.viewer.height() * 0.1)
|
321
|
+
self.viewer.verticalScrollBar().value() + amount
|
235
322
|
)
|
236
323
|
elif key == Qt.Key.Key_A and not (mods & Qt.KeyboardModifier.ControlModifier):
|
324
|
+
amount = int(self.viewer.width() * 0.1 * pan_multiplier)
|
237
325
|
self.viewer.horizontalScrollBar().setValue(
|
238
|
-
self.viewer.horizontalScrollBar().value()
|
239
|
-
- int(self.viewer.width() * 0.1)
|
326
|
+
self.viewer.horizontalScrollBar().value() - amount
|
240
327
|
)
|
241
328
|
elif key == Qt.Key.Key_D:
|
329
|
+
amount = int(self.viewer.width() * 0.1 * pan_multiplier)
|
242
330
|
self.viewer.horizontalScrollBar().setValue(
|
243
|
-
self.viewer.horizontalScrollBar().value()
|
244
|
-
+ int(self.viewer.width() * 0.1)
|
331
|
+
self.viewer.horizontalScrollBar().value() + amount
|
245
332
|
)
|
333
|
+
elif key == Qt.Key.Key_Period:
|
334
|
+
self.viewer.fitInView()
|
335
|
+
# Other keybindings
|
246
336
|
elif key == Qt.Key.Key_1:
|
247
337
|
self.set_sam_mode()
|
248
338
|
elif key == Qt.Key.Key_2:
|
@@ -265,14 +355,42 @@ class MainWindow(QMainWindow):
|
|
265
355
|
elif key == Qt.Key.Key_A and mods == Qt.KeyboardModifier.ControlModifier:
|
266
356
|
self.right_panel.segment_table.selectAll()
|
267
357
|
elif key == Qt.Key.Key_Space:
|
268
|
-
self.
|
358
|
+
if self.mode == "polygon" and self.polygon_points:
|
359
|
+
self.finalize_polygon()
|
360
|
+
else:
|
361
|
+
self.save_current_segment()
|
269
362
|
elif key == Qt.Key.Key_Return or key == Qt.Key.Key_Enter:
|
270
|
-
self.
|
363
|
+
if self.mode == "polygon" and self.polygon_points:
|
364
|
+
self.finalize_polygon()
|
365
|
+
else:
|
366
|
+
self.save_output_to_npz()
|
367
|
+
elif (
|
368
|
+
key == Qt.Key.Key_Equal or key == Qt.Key.Key_Plus
|
369
|
+
) and mods == Qt.KeyboardModifier.ControlModifier:
|
370
|
+
self.point_radius = min(20, self.point_radius + self._original_point_radius)
|
371
|
+
self.line_thickness = min(
|
372
|
+
20, self.line_thickness + self._original_line_thickness
|
373
|
+
)
|
374
|
+
self.display_all_segments()
|
375
|
+
self.clear_all_points()
|
376
|
+
elif key == Qt.Key.Key_Minus and mods == Qt.KeyboardModifier.ControlModifier:
|
377
|
+
self.point_radius = max(
|
378
|
+
0.3, self.point_radius - self._original_point_radius
|
379
|
+
)
|
380
|
+
self.line_thickness = max(
|
381
|
+
0.5, self.line_thickness - self._original_line_thickness
|
382
|
+
)
|
383
|
+
self.display_all_segments()
|
384
|
+
self.clear_all_points()
|
271
385
|
|
272
386
|
def scene_mouse_press(self, event):
|
273
387
|
self._original_mouse_press(event)
|
274
388
|
if event.isAccepted():
|
275
389
|
return
|
390
|
+
|
391
|
+
if self.mode == "pan":
|
392
|
+
self.viewer.set_cursor(Qt.CursorShape.ClosedHandCursor)
|
393
|
+
|
276
394
|
pos = event.scenePos()
|
277
395
|
if (
|
278
396
|
self.viewer._pixmap_item.pixmap().isNull()
|
@@ -314,10 +432,15 @@ class MainWindow(QMainWindow):
|
|
314
432
|
elif self.mode == "polygon" and self.polygon_points:
|
315
433
|
if self.rubber_band_line is None:
|
316
434
|
self.rubber_band_line = QGraphicsLineItem()
|
435
|
+
|
436
|
+
line_color = QColor(Qt.GlobalColor.white)
|
437
|
+
line_color.setAlpha(150)
|
438
|
+
|
317
439
|
self.rubber_band_line.setPen(
|
318
|
-
QPen(
|
440
|
+
QPen(line_color, self.line_thickness, Qt.PenStyle.DotLine)
|
319
441
|
)
|
320
442
|
self.viewer.scene().addItem(self.rubber_band_line)
|
443
|
+
|
321
444
|
self.rubber_band_line.setLine(
|
322
445
|
self.polygon_points[-1].x(),
|
323
446
|
self.polygon_points[-1].y(),
|
@@ -329,6 +452,9 @@ class MainWindow(QMainWindow):
|
|
329
452
|
self._original_mouse_move(event)
|
330
453
|
|
331
454
|
def scene_mouse_release(self, event):
|
455
|
+
if self.mode == "pan":
|
456
|
+
self.viewer.set_cursor(Qt.CursorShape.OpenHandCursor)
|
457
|
+
|
332
458
|
if self.mode == "edit" and self.is_dragging_polygon:
|
333
459
|
self.is_dragging_polygon = False
|
334
460
|
self.drag_initial_vertices.clear()
|
@@ -337,20 +463,55 @@ class MainWindow(QMainWindow):
|
|
337
463
|
def undo_last_action(self):
|
338
464
|
if self.mode == "polygon" and self.polygon_points:
|
339
465
|
self.polygon_points.pop()
|
340
|
-
|
341
|
-
|
466
|
+
|
467
|
+
for item in self.polygon_preview_items:
|
468
|
+
if item.scene():
|
469
|
+
self.viewer.scene().removeItem(item)
|
470
|
+
self.polygon_preview_items.clear()
|
471
|
+
|
472
|
+
for point in self.polygon_points:
|
473
|
+
point_diameter = self.point_radius * 2
|
474
|
+
point_color = QColor(Qt.GlobalColor.blue)
|
475
|
+
point_color.setAlpha(150)
|
476
|
+
dot = QGraphicsEllipseItem(
|
477
|
+
point.x() - self.point_radius,
|
478
|
+
point.y() - self.point_radius,
|
479
|
+
point_diameter,
|
480
|
+
point_diameter,
|
481
|
+
)
|
482
|
+
dot.setBrush(QBrush(point_color))
|
483
|
+
dot.setPen(QPen(Qt.GlobalColor.transparent))
|
484
|
+
self.viewer.scene().addItem(dot)
|
485
|
+
self.polygon_preview_items.append(dot)
|
486
|
+
|
342
487
|
self.draw_polygon_preview()
|
488
|
+
|
343
489
|
elif self.mode == "sam_points" and self.point_items:
|
344
490
|
item_to_remove = self.point_items.pop()
|
345
|
-
point_pos = item_to_remove.rect().topLeft() + QPointF(
|
491
|
+
point_pos = item_to_remove.rect().topLeft() + QPointF(
|
492
|
+
self.point_radius, self.point_radius
|
493
|
+
)
|
346
494
|
point_coords = [int(point_pos.x()), int(point_pos.y())]
|
495
|
+
|
347
496
|
if point_coords in self.positive_points:
|
348
497
|
self.positive_points.remove(point_coords)
|
349
498
|
elif point_coords in self.negative_points:
|
350
499
|
self.negative_points.remove(point_coords)
|
500
|
+
|
351
501
|
self.viewer.scene().removeItem(item_to_remove)
|
352
502
|
self.update_segmentation()
|
353
503
|
|
504
|
+
def _update_next_class_id(self):
|
505
|
+
all_ids = {
|
506
|
+
seg.get("class_id")
|
507
|
+
for seg in self.segments
|
508
|
+
if seg.get("class_id") is not None
|
509
|
+
}
|
510
|
+
if not all_ids:
|
511
|
+
self.next_class_id = 0
|
512
|
+
else:
|
513
|
+
self.next_class_id = max(all_ids) + 1
|
514
|
+
|
354
515
|
def finalize_polygon(self):
|
355
516
|
if len(self.polygon_points) < 3:
|
356
517
|
return
|
@@ -365,7 +526,7 @@ class MainWindow(QMainWindow):
|
|
365
526
|
"class_id": self.next_class_id,
|
366
527
|
}
|
367
528
|
)
|
368
|
-
self.
|
529
|
+
self._update_next_class_id()
|
369
530
|
self.polygon_points.clear()
|
370
531
|
for item in self.polygon_preview_items:
|
371
532
|
self.viewer.scene().removeItem(item)
|
@@ -418,6 +579,7 @@ class MainWindow(QMainWindow):
|
|
418
579
|
for i in selected_indices:
|
419
580
|
self.segments[i]["class_id"] = target_class_id
|
420
581
|
|
582
|
+
self._update_next_class_id()
|
421
583
|
self.update_all_lists()
|
422
584
|
self.right_panel.segment_table.clearSelection()
|
423
585
|
self.viewer.setFocus()
|
@@ -444,7 +606,8 @@ class MainWindow(QMainWindow):
|
|
444
606
|
for i, seg_dict in enumerate(self.segments):
|
445
607
|
self.segment_items[i] = []
|
446
608
|
class_id = seg_dict.get("class_id")
|
447
|
-
|
609
|
+
|
610
|
+
base_color = self._get_color_for_class(class_id)
|
448
611
|
|
449
612
|
if seg_dict["type"] == "Polygon":
|
450
613
|
poly_item = HoverablePolygonItem(QPolygonF(seg_dict["vertices"]))
|
@@ -458,21 +621,47 @@ class MainWindow(QMainWindow):
|
|
458
621
|
poly_item.setPen(QPen(Qt.GlobalColor.transparent))
|
459
622
|
self.viewer.scene().addItem(poly_item)
|
460
623
|
self.segment_items[i].append(poly_item)
|
624
|
+
|
625
|
+
base_color.setAlpha(150)
|
461
626
|
vertex_color = QBrush(base_color)
|
627
|
+
point_diameter = self.point_radius * 2
|
462
628
|
for v in seg_dict["vertices"]:
|
463
|
-
dot = QGraphicsEllipseItem(
|
629
|
+
dot = QGraphicsEllipseItem(
|
630
|
+
v.x() - self.point_radius,
|
631
|
+
v.y() - self.point_radius,
|
632
|
+
point_diameter,
|
633
|
+
point_diameter,
|
634
|
+
)
|
464
635
|
dot.setBrush(vertex_color)
|
636
|
+
dot.setPen(QPen(Qt.GlobalColor.transparent))
|
465
637
|
self.viewer.scene().addItem(dot)
|
466
638
|
self.segment_items[i].append(dot)
|
467
639
|
if self.mode == "edit" and i in selected_indices:
|
640
|
+
handle_diameter = self.point_radius * 2
|
468
641
|
for idx, v in enumerate(seg_dict["vertices"]):
|
469
|
-
vertex_item = EditableVertexItem(
|
642
|
+
vertex_item = EditableVertexItem(
|
643
|
+
self,
|
644
|
+
i,
|
645
|
+
idx,
|
646
|
+
-handle_diameter / 2,
|
647
|
+
-handle_diameter / 2,
|
648
|
+
handle_diameter,
|
649
|
+
handle_diameter,
|
650
|
+
)
|
470
651
|
vertex_item.setPos(v)
|
471
652
|
self.viewer.scene().addItem(vertex_item)
|
472
653
|
self.segment_items[i].append(vertex_item)
|
473
654
|
elif seg_dict.get("mask") is not None:
|
474
|
-
|
475
|
-
|
655
|
+
default_pixmap = mask_to_pixmap(
|
656
|
+
seg_dict["mask"], base_color.getRgb()[:3], alpha=70
|
657
|
+
)
|
658
|
+
hover_pixmap = mask_to_pixmap(
|
659
|
+
seg_dict["mask"], base_color.getRgb()[:3], alpha=170
|
660
|
+
)
|
661
|
+
|
662
|
+
pixmap_item = HoverablePixmapItem()
|
663
|
+
pixmap_item.set_pixmaps(default_pixmap, hover_pixmap)
|
664
|
+
self.viewer.scene().addItem(pixmap_item)
|
476
665
|
pixmap_item.setZValue(i + 1)
|
477
666
|
self.segment_items[i].append(pixmap_item)
|
478
667
|
self.highlight_selected_segments()
|
@@ -508,9 +697,9 @@ class MainWindow(QMainWindow):
|
|
508
697
|
self.highlight_items.append(highlight_item)
|
509
698
|
|
510
699
|
def update_all_lists(self):
|
700
|
+
self.update_class_list() # Must be before filter combo
|
511
701
|
self.update_class_filter_combo()
|
512
702
|
self.update_segment_table()
|
513
|
-
self.update_class_list()
|
514
703
|
self.display_all_segments()
|
515
704
|
|
516
705
|
def update_segment_table(self):
|
@@ -524,7 +713,7 @@ class MainWindow(QMainWindow):
|
|
524
713
|
filter_class_id = -1
|
525
714
|
if not show_all:
|
526
715
|
try:
|
527
|
-
filter_class_id = int(filter_text.split(" ")[1])
|
716
|
+
filter_class_id = int(filter_text.split("(ID: ")[1][:-1])
|
528
717
|
except (ValueError, IndexError):
|
529
718
|
pass
|
530
719
|
|
@@ -537,7 +726,7 @@ class MainWindow(QMainWindow):
|
|
537
726
|
|
538
727
|
for row, (original_index, seg) in enumerate(display_segments):
|
539
728
|
class_id = seg.get("class_id")
|
540
|
-
color = self._get_color_for_class(class_id
|
729
|
+
color = self._get_color_for_class(class_id)
|
541
730
|
|
542
731
|
class_id_str = str(class_id) if class_id is not None else "N/A"
|
543
732
|
index_item = NumericTableWidgetItem(str(original_index + 1))
|
@@ -552,7 +741,7 @@ class MainWindow(QMainWindow):
|
|
552
741
|
table.setItem(row, 1, class_item)
|
553
742
|
table.setItem(row, 2, type_item)
|
554
743
|
|
555
|
-
for col in range(
|
744
|
+
for col in range(table.columnCount()):
|
556
745
|
if table.item(row, col):
|
557
746
|
table.item(row, col).setBackground(QBrush(color))
|
558
747
|
|
@@ -569,6 +758,18 @@ class MainWindow(QMainWindow):
|
|
569
758
|
def update_class_list(self):
|
570
759
|
class_table = self.right_panel.class_table
|
571
760
|
class_table.blockSignals(True)
|
761
|
+
|
762
|
+
# Preserve existing aliases during update
|
763
|
+
current_aliases = {}
|
764
|
+
for row in range(class_table.rowCount()):
|
765
|
+
try:
|
766
|
+
alias = class_table.item(row, 0).text()
|
767
|
+
cid = int(class_table.item(row, 1).text())
|
768
|
+
current_aliases[cid] = alias
|
769
|
+
except (AttributeError, ValueError):
|
770
|
+
continue
|
771
|
+
self.class_aliases.update(current_aliases)
|
772
|
+
|
572
773
|
class_table.clearContents()
|
573
774
|
|
574
775
|
unique_class_ids = sorted(
|
@@ -583,13 +784,18 @@ class MainWindow(QMainWindow):
|
|
583
784
|
class_table.setRowCount(len(unique_class_ids))
|
584
785
|
|
585
786
|
for row, cid in enumerate(unique_class_ids):
|
586
|
-
|
587
|
-
|
787
|
+
alias = self.class_aliases.get(cid, str(cid))
|
788
|
+
alias_item = QTableWidgetItem(alias)
|
789
|
+
id_item = QTableWidgetItem(str(cid))
|
588
790
|
|
589
|
-
|
791
|
+
id_item.setFlags(id_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
590
792
|
|
591
|
-
|
592
|
-
|
793
|
+
color = self._get_color_for_class(cid)
|
794
|
+
alias_item.setBackground(QBrush(color))
|
795
|
+
id_item.setBackground(QBrush(color))
|
796
|
+
|
797
|
+
class_table.setItem(row, 0, alias_item)
|
798
|
+
class_table.setItem(row, 1, id_item)
|
593
799
|
|
594
800
|
class_table.blockSignals(False)
|
595
801
|
|
@@ -604,11 +810,18 @@ class MainWindow(QMainWindow):
|
|
604
810
|
}
|
605
811
|
)
|
606
812
|
)
|
813
|
+
|
607
814
|
current_selection = combo.currentText()
|
608
815
|
combo.blockSignals(True)
|
609
816
|
combo.clear()
|
610
817
|
combo.addItem("All Classes")
|
611
|
-
combo.addItems(
|
818
|
+
combo.addItems(
|
819
|
+
[
|
820
|
+
f"{self.class_aliases.get(cid, cid)} (ID: {cid})"
|
821
|
+
for cid in unique_class_ids
|
822
|
+
]
|
823
|
+
)
|
824
|
+
|
612
825
|
if combo.findText(current_selection) > -1:
|
613
826
|
combo.setCurrentText(current_selection)
|
614
827
|
else:
|
@@ -617,22 +830,54 @@ class MainWindow(QMainWindow):
|
|
617
830
|
|
618
831
|
def reassign_class_ids(self):
|
619
832
|
class_table = self.right_panel.class_table
|
620
|
-
|
621
|
-
|
622
|
-
|
623
|
-
|
624
|
-
|
833
|
+
|
834
|
+
ordered_ids = []
|
835
|
+
for row in range(class_table.rowCount()):
|
836
|
+
id_item = class_table.item(row, 1)
|
837
|
+
if id_item and id_item.text():
|
838
|
+
try:
|
839
|
+
ordered_ids.append(int(id_item.text()))
|
840
|
+
except ValueError:
|
841
|
+
continue
|
842
|
+
|
625
843
|
id_map = {old_id: new_id for new_id, old_id in enumerate(ordered_ids)}
|
844
|
+
|
626
845
|
for seg in self.segments:
|
627
846
|
old_id = seg.get("class_id")
|
628
847
|
if old_id in id_map:
|
629
848
|
seg["class_id"] = id_map[old_id]
|
630
|
-
|
849
|
+
|
850
|
+
new_aliases = {
|
851
|
+
id_map[old_id]: self.class_aliases.get(old_id, str(old_id))
|
852
|
+
for old_id in ordered_ids
|
853
|
+
if old_id in self.class_aliases
|
854
|
+
}
|
855
|
+
self.class_aliases = new_aliases
|
856
|
+
|
857
|
+
self._update_next_class_id()
|
631
858
|
self.update_all_lists()
|
632
859
|
self.viewer.setFocus()
|
633
860
|
|
861
|
+
def handle_alias_change(self, item):
|
862
|
+
if item.column() != 0: # Alias column
|
863
|
+
return
|
864
|
+
|
865
|
+
class_table = self.right_panel.class_table
|
866
|
+
class_table.blockSignals(True)
|
867
|
+
|
868
|
+
id_item = class_table.item(item.row(), 1)
|
869
|
+
if id_item:
|
870
|
+
try:
|
871
|
+
class_id = int(id_item.text())
|
872
|
+
self.class_aliases[class_id] = item.text()
|
873
|
+
except (ValueError, AttributeError):
|
874
|
+
pass # Ignore if ID item is not valid
|
875
|
+
|
876
|
+
class_table.blockSignals(False)
|
877
|
+
self.update_class_filter_combo() # Refresh filter to show new alias
|
878
|
+
|
634
879
|
def handle_class_id_change(self, item):
|
635
|
-
if item.column() != 1:
|
880
|
+
if item.column() != 1: # Class ID column in segment table
|
636
881
|
return
|
637
882
|
table = self.right_panel.segment_table
|
638
883
|
index_item = table.item(item.row(), 0)
|
@@ -651,8 +896,7 @@ class MainWindow(QMainWindow):
|
|
651
896
|
raise IndexError("Invalid segment index found in table.")
|
652
897
|
|
653
898
|
self.segments[original_index]["class_id"] = new_class_id
|
654
|
-
|
655
|
-
self.next_class_id = new_class_id + 1
|
899
|
+
self._update_next_class_id()
|
656
900
|
self.update_all_lists()
|
657
901
|
except (ValueError, TypeError, AttributeError, IndexError) as e:
|
658
902
|
original_index = index_item.data(Qt.ItemDataRole.UserRole)
|
@@ -686,22 +930,21 @@ class MainWindow(QMainWindow):
|
|
686
930
|
self.viewer._pixmap_item.pixmap().height(),
|
687
931
|
self.viewer._pixmap_item.pixmap().width(),
|
688
932
|
)
|
689
|
-
|
690
|
-
|
691
|
-
|
692
|
-
|
693
|
-
|
694
|
-
|
695
|
-
|
696
|
-
|
697
|
-
|
698
|
-
|
699
|
-
self.right_panel.status_label.setText("Save failed: No classes.")
|
933
|
+
|
934
|
+
class_table = self.right_panel.class_table
|
935
|
+
ordered_ids = [
|
936
|
+
int(class_table.item(row, 1).text())
|
937
|
+
for row in range(class_table.rowCount())
|
938
|
+
if class_table.item(row, 1) is not None
|
939
|
+
]
|
940
|
+
|
941
|
+
if not ordered_ids:
|
942
|
+
self.right_panel.status_label.setText("Save failed: No classes defined.")
|
700
943
|
QTimer.singleShot(3000, lambda: self.right_panel.status_label.clear())
|
701
944
|
return
|
702
945
|
|
703
|
-
id_map = {old_id: new_id for new_id, old_id in enumerate(
|
704
|
-
num_final_classes = len(
|
946
|
+
id_map = {old_id: new_id for new_id, old_id in enumerate(ordered_ids)}
|
947
|
+
num_final_classes = len(ordered_ids)
|
705
948
|
final_mask_tensor = np.zeros((h, w, num_final_classes), dtype=np.uint8)
|
706
949
|
|
707
950
|
for seg in self.segments:
|
@@ -720,11 +963,52 @@ class MainWindow(QMainWindow):
|
|
720
963
|
)
|
721
964
|
|
722
965
|
np.savez_compressed(output_path, mask=final_mask_tensor.astype(np.uint8))
|
723
|
-
|
966
|
+
|
967
|
+
self.file_model.set_highlighted_path(output_path)
|
968
|
+
QTimer.singleShot(1500, lambda: self.file_model.set_highlighted_path(None))
|
724
969
|
|
725
970
|
self.right_panel.status_label.setText("Saved!")
|
971
|
+
self.generate_yolo_annotations(npz_file_path=output_path)
|
726
972
|
QTimer.singleShot(3000, lambda: self.right_panel.status_label.clear())
|
727
973
|
|
974
|
+
def generate_yolo_annotations(self, npz_file_path):
|
975
|
+
output_path = os.path.splitext(self.current_image_path)[0] + ".txt"
|
976
|
+
npz_data = np.load(npz_file_path) # Load the saved npz file
|
977
|
+
|
978
|
+
img = npz_data["mask"][:, :, :]
|
979
|
+
num_channels = img.shape[2] # C
|
980
|
+
h, w = img.shape[:2] # H, W
|
981
|
+
|
982
|
+
directory_path = os.path.dirname(output_path)
|
983
|
+
os.makedirs(directory_path, exist_ok=True)
|
984
|
+
|
985
|
+
yolo_annotations = []
|
986
|
+
|
987
|
+
for channel in range(num_channels):
|
988
|
+
single_channel_image = img[:, :, channel]
|
989
|
+
contours, _ = cv2.findContours(
|
990
|
+
single_channel_image, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
|
991
|
+
)
|
992
|
+
|
993
|
+
class_id = channel # Use the channel index as the class ID
|
994
|
+
|
995
|
+
for contour in contours:
|
996
|
+
x, y, width, height = cv2.boundingRect(contour)
|
997
|
+
center_x = x + width / 2
|
998
|
+
center_y = y + height / 2
|
999
|
+
|
1000
|
+
normalized_center_x = center_x / w
|
1001
|
+
normalized_center_y = center_y / h
|
1002
|
+
normalized_width = width / w
|
1003
|
+
normalized_height = height / h
|
1004
|
+
|
1005
|
+
yolo_entry = f"{class_id} {normalized_center_x} {normalized_center_y} {normalized_width} {normalized_height}"
|
1006
|
+
yolo_annotations.append(yolo_entry)
|
1007
|
+
|
1008
|
+
with open(output_path, "w") as file:
|
1009
|
+
for annotation in yolo_annotations:
|
1010
|
+
file.write(annotation + "\n")
|
1011
|
+
|
728
1012
|
def save_current_segment(self):
|
729
1013
|
if (
|
730
1014
|
self.mode != "sam_points"
|
@@ -742,7 +1026,7 @@ class MainWindow(QMainWindow):
|
|
742
1026
|
"class_id": self.next_class_id,
|
743
1027
|
}
|
744
1028
|
)
|
745
|
-
self.
|
1029
|
+
self._update_next_class_id()
|
746
1030
|
self.clear_all_points()
|
747
1031
|
self.update_all_lists()
|
748
1032
|
|
@@ -752,6 +1036,7 @@ class MainWindow(QMainWindow):
|
|
752
1036
|
return
|
753
1037
|
for i in sorted(selected_indices, reverse=True):
|
754
1038
|
del self.segments[i]
|
1039
|
+
self._update_next_class_id()
|
755
1040
|
self.update_all_lists()
|
756
1041
|
self.viewer.setFocus()
|
757
1042
|
|
@@ -777,16 +1062,27 @@ class MainWindow(QMainWindow):
|
|
777
1062
|
"class_id": i,
|
778
1063
|
}
|
779
1064
|
)
|
780
|
-
self.
|
1065
|
+
self._update_next_class_id()
|
781
1066
|
self.update_all_lists()
|
782
1067
|
|
783
1068
|
def add_point(self, pos, positive):
|
784
1069
|
point_list = self.positive_points if positive else self.negative_points
|
785
1070
|
point_list.append([int(pos.x()), int(pos.y())])
|
786
|
-
|
787
|
-
|
788
|
-
|
789
|
-
|
1071
|
+
|
1072
|
+
point_color = (
|
1073
|
+
QColor(Qt.GlobalColor.green) if positive else QColor(Qt.GlobalColor.red)
|
1074
|
+
)
|
1075
|
+
point_color.setAlpha(150)
|
1076
|
+
|
1077
|
+
point_diameter = self.point_radius * 2
|
1078
|
+
point_item = QGraphicsEllipseItem(
|
1079
|
+
pos.x() - self.point_radius,
|
1080
|
+
pos.y() - self.point_radius,
|
1081
|
+
point_diameter,
|
1082
|
+
point_diameter,
|
1083
|
+
)
|
1084
|
+
point_item.setBrush(QBrush(point_color))
|
1085
|
+
point_item.setPen(QPen(Qt.GlobalColor.transparent))
|
790
1086
|
self.viewer.scene().addItem(point_item)
|
791
1087
|
self.point_items.append(point_item)
|
792
1088
|
|
@@ -824,26 +1120,35 @@ class MainWindow(QMainWindow):
|
|
824
1120
|
(pos.x() - self.polygon_points[0].x()) ** 2
|
825
1121
|
+ (pos.y() - self.polygon_points[0].y()) ** 2
|
826
1122
|
)
|
827
|
-
<
|
1123
|
+
< 4 # pixel distance threshold squared
|
828
1124
|
):
|
829
1125
|
if len(self.polygon_points) > 2:
|
830
1126
|
self.finalize_polygon()
|
831
1127
|
return
|
832
1128
|
self.polygon_points.append(pos)
|
833
|
-
|
834
|
-
|
835
|
-
|
1129
|
+
point_diameter = self.point_radius * 2
|
1130
|
+
|
1131
|
+
point_color = QColor(Qt.GlobalColor.blue)
|
1132
|
+
point_color.setAlpha(150)
|
1133
|
+
|
1134
|
+
dot = QGraphicsEllipseItem(
|
1135
|
+
pos.x() - self.point_radius,
|
1136
|
+
pos.y() - self.point_radius,
|
1137
|
+
point_diameter,
|
1138
|
+
point_diameter,
|
1139
|
+
)
|
1140
|
+
dot.setBrush(QBrush(point_color))
|
1141
|
+
dot.setPen(QPen(Qt.GlobalColor.transparent))
|
836
1142
|
self.viewer.scene().addItem(dot)
|
837
1143
|
self.polygon_preview_items.append(dot)
|
838
1144
|
self.draw_polygon_preview()
|
839
1145
|
|
840
1146
|
def draw_polygon_preview(self):
|
841
|
-
|
842
|
-
self.viewer.scene().removeItem(self.rubber_band_line)
|
843
|
-
self.rubber_band_line = None
|
1147
|
+
# Clean up old preview lines/polygons
|
844
1148
|
for item in self.polygon_preview_items:
|
845
1149
|
if not isinstance(item, QGraphicsEllipseItem):
|
846
|
-
|
1150
|
+
if item.scene():
|
1151
|
+
self.viewer.scene().removeItem(item)
|
847
1152
|
self.polygon_preview_items = [
|
848
1153
|
item
|
849
1154
|
for item in self.polygon_preview_items
|
@@ -858,6 +1163,8 @@ class MainWindow(QMainWindow):
|
|
858
1163
|
self.polygon_preview_items.append(preview_poly)
|
859
1164
|
|
860
1165
|
if len(self.polygon_points) > 1:
|
1166
|
+
line_color = QColor(Qt.GlobalColor.cyan)
|
1167
|
+
line_color.setAlpha(150)
|
861
1168
|
for i in range(len(self.polygon_points) - 1):
|
862
1169
|
line = QGraphicsLineItem(
|
863
1170
|
self.polygon_points[i].x(),
|
@@ -865,7 +1172,7 @@ class MainWindow(QMainWindow):
|
|
865
1172
|
self.polygon_points[i + 1].x(),
|
866
1173
|
self.polygon_points[i + 1].y(),
|
867
1174
|
)
|
868
|
-
line.setPen(QPen(
|
1175
|
+
line.setPen(QPen(line_color, self.line_thickness))
|
869
1176
|
self.viewer.scene().addItem(line)
|
870
1177
|
self.polygon_preview_items.append(line)
|
871
1178
|
|
@@ -1,5 +1,5 @@
|
|
1
1
|
from PyQt6.QtCore import Qt, QRectF
|
2
|
-
from PyQt6.QtGui import QPixmap
|
2
|
+
from PyQt6.QtGui import QPixmap, QCursor
|
3
3
|
from PyQt6.QtWidgets import QGraphicsView, QGraphicsScene, QGraphicsPixmapItem
|
4
4
|
|
5
5
|
|
@@ -21,8 +21,7 @@ class PhotoViewer(QGraphicsView):
|
|
21
21
|
rect = QRectF(self._pixmap_item.pixmap().rect())
|
22
22
|
if not rect.isNull():
|
23
23
|
self.setSceneRect(rect)
|
24
|
-
|
25
|
-
self.scale(1 / unity.width(), 1 / unity.height())
|
24
|
+
self.resetTransform()
|
26
25
|
viewrect = self.viewport().rect()
|
27
26
|
scenerect = self.transform().mapRect(rect)
|
28
27
|
factor = min(
|
@@ -30,6 +29,7 @@ class PhotoViewer(QGraphicsView):
|
|
30
29
|
viewrect.height() / scenerect.height(),
|
31
30
|
)
|
32
31
|
self.scale(factor, factor)
|
32
|
+
self.centerOn(self._pixmap_item)
|
33
33
|
|
34
34
|
def set_photo(self, pixmap):
|
35
35
|
if pixmap and not pixmap.isNull():
|
@@ -38,6 +38,9 @@ class PhotoViewer(QGraphicsView):
|
|
38
38
|
else:
|
39
39
|
self._pixmap_item.setPixmap(QPixmap())
|
40
40
|
|
41
|
+
def set_cursor(self, cursor_shape):
|
42
|
+
self.viewport().setCursor(QCursor(cursor_shape))
|
43
|
+
|
41
44
|
def resizeEvent(self, event):
|
42
45
|
self.fitInView()
|
43
46
|
super().resizeEvent(event)
|
@@ -34,11 +34,13 @@ class ReorderableClassTable(QTableWidget):
|
|
34
34
|
list({index.row() for index in self.selectedIndexes()}), reverse=True
|
35
35
|
)
|
36
36
|
|
37
|
-
|
37
|
+
dragged_rows_data = []
|
38
38
|
for row in selected_rows:
|
39
|
-
# Take
|
40
|
-
|
41
|
-
|
39
|
+
# Take all items from the row
|
40
|
+
row_data = [
|
41
|
+
self.takeItem(row, col) for col in range(self.columnCount())
|
42
|
+
]
|
43
|
+
dragged_rows_data.insert(0, row_data)
|
42
44
|
# Then remove the row itself
|
43
45
|
self.removeRow(row)
|
44
46
|
|
@@ -47,10 +49,11 @@ class ReorderableClassTable(QTableWidget):
|
|
47
49
|
if row < drop_row:
|
48
50
|
drop_row -= 1
|
49
51
|
|
50
|
-
# Insert items at the new location
|
51
|
-
for
|
52
|
+
# Insert rows and their items at the new location
|
53
|
+
for row_data in dragged_rows_data:
|
52
54
|
self.insertRow(drop_row)
|
53
|
-
|
55
|
+
for col, item in enumerate(row_data):
|
56
|
+
self.setItem(drop_row, col, item)
|
54
57
|
self.selectRow(drop_row)
|
55
58
|
drop_row += 1
|
56
59
|
|
@@ -2,10 +2,10 @@ import numpy as np
|
|
2
2
|
from PyQt6.QtGui import QImage, QPixmap
|
3
3
|
|
4
4
|
|
5
|
-
def mask_to_pixmap(mask, color):
|
5
|
+
def mask_to_pixmap(mask, color, alpha=150):
|
6
6
|
colored_mask = np.zeros((mask.shape[0], mask.shape[1], 4), dtype=np.uint8)
|
7
7
|
colored_mask[mask, :3] = color
|
8
|
-
colored_mask[mask, 3] =
|
8
|
+
colored_mask[mask, 3] = alpha # Alpha channel for transparency
|
9
9
|
image = QImage(
|
10
10
|
colored_mask.data, mask.shape[1], mask.shape[0], QImage.Format.Format_RGBA8888
|
11
11
|
)
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: lazylabel-gui
|
3
|
-
Version: 1.0.
|
3
|
+
Version: 1.0.7
|
4
4
|
Summary: An image segmentation GUI for generating mask tensors.
|
5
5
|
Author-email: "Deniz N. Cakan" <deniz.n.cakan@gmail.com>
|
6
6
|
License: MIT License
|
@@ -48,7 +48,7 @@ Requires-Dist: tqdm>=4.67.1
|
|
48
48
|
Dynamic: license-file
|
49
49
|
|
50
50
|
# <img src="https://raw.githubusercontent.com/dnzckn/LazyLabel/main/src/lazylabel/demo_pictures/logo2.png" alt="LazyLabel Logo" style="height:60px; vertical-align:middle;" /> <img src="https://raw.githubusercontent.com/dnzckn/LazyLabel/main/src/lazylabel/demo_pictures/logo_black.png" alt="LazyLabel Cursive" style="height:60px; vertical-align:middle;" />
|
51
|
-
LazyLabel is an intuitive, AI-assisted image segmentation tool. It uses Meta's Segment Anything Model (SAM) for quick, precise mask generation, alongside advanced polygon editing for fine-tuned control. Outputs are saved in a clean, one-hot encoded format for easy machine learning integration.
|
51
|
+
LazyLabel is an intuitive, AI-assisted image segmentation tool. It uses Meta's Segment Anything Model (SAM) for quick, precise mask generation, alongside advanced polygon editing for fine-tuned control. Outputs are saved in a clean, one-hot encoded `.npz` format for easy machine learning integration and in YOLO `.txt` format.
|
52
52
|
|
53
53
|
Inspired by [LabelMe](https://github.com/wkentaro/labelme?tab=readme-ov-file#installation) and [Segment-Anything-UI](https://github.com/branislavhesko/segment-anything-ui/tree/main).
|
54
54
|
|
@@ -4,6 +4,7 @@ pyproject.toml
|
|
4
4
|
src/lazylabel/controls.py
|
5
5
|
src/lazylabel/custom_file_system_model.py
|
6
6
|
src/lazylabel/editable_vertex.py
|
7
|
+
src/lazylabel/hoverable_pixelmap_item.py
|
7
8
|
src/lazylabel/hoverable_polygon_item.py
|
8
9
|
src/lazylabel/main.py
|
9
10
|
src/lazylabel/numeric_table_widget_item.py
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|