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.
Files changed (22) hide show
  1. {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/PKG-INFO +2 -2
  2. {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/README.md +1 -1
  3. {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/pyproject.toml +1 -1
  4. {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel/controls.py +59 -4
  5. {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel/custom_file_system_model.py +10 -4
  6. {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel/editable_vertex.py +7 -3
  7. lazylabel_gui-1.0.7/src/lazylabel/hoverable_pixelmap_item.py +22 -0
  8. {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel/main.py +387 -80
  9. {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel/photo_viewer.py +6 -3
  10. {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel/reorderable_class_table.py +10 -7
  11. {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel/utils.py +2 -2
  12. {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel_gui.egg-info/PKG-INFO +2 -2
  13. {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel_gui.egg-info/SOURCES.txt +1 -0
  14. {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/LICENSE +0 -0
  15. {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/setup.cfg +0 -0
  16. {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel/hoverable_polygon_item.py +0 -0
  17. {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel/numeric_table_widget_item.py +0 -0
  18. {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel/sam_model.py +0 -0
  19. {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel_gui.egg-info/dependency_links.txt +0 -0
  20. {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel_gui.egg-info/entry_points.txt +0 -0
  21. {lazylabel_gui-1.0.5 → lazylabel_gui-1.0.7}/src/lazylabel_gui.egg-info/requires.txt +0 -0
  22. {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.5
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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lazylabel-gui"
7
- version = "1.0.5"
7
+ version = "1.0.7"
8
8
  authors = [
9
9
  { name="Deniz N. Cakan", email="deniz.n.cakan@gmail.com" },
10
10
  ]
@@ -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.setColumnCount(1)
99
- self.class_table.setHorizontalHeaderLabels(["Class ID"])
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 filePath == self.highlighted_path:
44
- return QBrush(QColor("yellow"))
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
- self.setBrush(QBrush(Qt.GlobalColor.cyan))
15
- self.setPen(QPen(Qt.GlobalColor.white, 1))
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 QPixmap, QColor, QPen, QBrush, QPolygonF, QIcon
20
- from PyQt6.QtCore import Qt, QPointF, QTimer
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 = None
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, saturation, value):
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, saturation, value)
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
- if self.mode == "edit":
175
- self.set_mode("selection", is_toggle=True)
176
- elif self.mode == "selection" and can_edit:
177
- self.set_mode("edit", is_toggle=True)
178
- self.display_all_segments()
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 and not mods:
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.save_current_segment()
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.save_output_to_npz()
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(Qt.GlobalColor.white, 2, Qt.PenStyle.DotLine)
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
- if self.polygon_preview_items:
341
- self.viewer.scene().removeItem(self.polygon_preview_items.pop())
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(4, 4)
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.next_class_id += 1
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
- base_color = self._get_color_for_class(class_id, saturation=220, value=220)
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(v.x() - 3, v.y() - 3, 6, 6)
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(self, i, idx, -4, -4, 8, 8)
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
- pixmap = mask_to_pixmap(seg_dict["mask"], base_color.getRgb()[:3])
475
- pixmap_item = self.viewer.scene().addPixmap(pixmap)
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, saturation=180, value=200)
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(3):
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
- item = QTableWidgetItem(str(cid))
587
- item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
787
+ alias = self.class_aliases.get(cid, str(cid))
788
+ alias_item = QTableWidgetItem(alias)
789
+ id_item = QTableWidgetItem(str(cid))
588
790
 
589
- color = self._get_color_for_class(cid, saturation=180, value=200)
791
+ id_item.setFlags(id_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
590
792
 
591
- item.setBackground(QBrush(color))
592
- class_table.setItem(row, 0, item)
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([f"Class {cid}" for cid in unique_class_ids])
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
- ordered_ids = [
621
- int(class_table.item(row, 0).text())
622
- for row in range(class_table.rowCount())
623
- if class_table.item(row, 0) is not None
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
- self.next_class_id = len(ordered_ids)
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
- if new_class_id >= self.next_class_id:
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
- unique_class_ids = sorted(
690
- list(
691
- {
692
- seg["class_id"]
693
- for seg in self.segments
694
- if seg.get("class_id") is not None
695
- }
696
- )
697
- )
698
- if not unique_class_ids:
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(unique_class_ids)}
704
- num_final_classes = len(unique_class_ids)
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
- self.file_model.setRootPath(self.file_model.rootPath())
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.next_class_id += 1
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.next_class_id = num_classes
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
- color = Qt.GlobalColor.green if positive else Qt.GlobalColor.red
787
- point_item = QGraphicsEllipseItem(pos.x() - 4, pos.y() - 4, 8, 8)
788
- point_item.setBrush(QBrush(color))
789
- point_item.setPen(QPen(Qt.GlobalColor.white))
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
- < 25
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
- dot = QGraphicsEllipseItem(pos.x() - 2, pos.y() - 2, 4, 4)
834
- dot.setBrush(QBrush(Qt.GlobalColor.blue))
835
- dot.setPen(QPen(Qt.GlobalColor.cyan))
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
- if self.rubber_band_line:
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
- self.viewer.scene().removeItem(item)
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(Qt.GlobalColor.cyan, 2))
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
- unity = self.transform().mapRect(QRectF(0, 0, 1, 1))
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
- dragged_items = []
37
+ dragged_rows_data = []
38
38
  for row in selected_rows:
39
- # Take the item from the row and keep its data
40
- item = self.takeItem(row, 0)
41
- dragged_items.insert(0, item)
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 item in dragged_items:
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
- self.setItem(drop_row, 0, item)
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] = 150 # Alpha channel for transparency
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.5
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