lazylabel-gui 1.3.7__py3-none-any.whl → 1.3.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lazylabel/config/hotkeys.py +13 -0
- lazylabel/config/settings.py +4 -0
- lazylabel/core/file_manager.py +6 -2
- lazylabel/core/segment_manager.py +233 -1
- lazylabel/ui/main_window.py +361 -95
- lazylabel/ui/widgets/settings_widget.py +61 -0
- {lazylabel_gui-1.3.7.dist-info → lazylabel_gui-1.3.9.dist-info}/METADATA +1 -1
- {lazylabel_gui-1.3.7.dist-info → lazylabel_gui-1.3.9.dist-info}/RECORD +12 -12
- {lazylabel_gui-1.3.7.dist-info → lazylabel_gui-1.3.9.dist-info}/WHEEL +0 -0
- {lazylabel_gui-1.3.7.dist-info → lazylabel_gui-1.3.9.dist-info}/entry_points.txt +0 -0
- {lazylabel_gui-1.3.7.dist-info → lazylabel_gui-1.3.9.dist-info}/licenses/LICENSE +0 -0
- {lazylabel_gui-1.3.7.dist-info → lazylabel_gui-1.3.9.dist-info}/top_level.txt +0 -0
lazylabel/config/hotkeys.py
CHANGED
@@ -57,6 +57,12 @@ class HotkeyManager:
|
|
57
57
|
HotkeyAction(
|
58
58
|
"save_segment", "Save Current Segment", "Space", category="Actions"
|
59
59
|
),
|
60
|
+
HotkeyAction(
|
61
|
+
"erase_segment",
|
62
|
+
"Erase with Current Segment",
|
63
|
+
"Shift+Space",
|
64
|
+
category="Actions",
|
65
|
+
),
|
60
66
|
HotkeyAction("save_output", "Save Output", "Return", category="Actions"),
|
61
67
|
HotkeyAction(
|
62
68
|
"save_output_alt", "Save Output (Alt)", "Enter", category="Actions"
|
@@ -68,6 +74,9 @@ class HotkeyManager:
|
|
68
74
|
HotkeyAction(
|
69
75
|
"escape", "Cancel/Clear Selection", "Escape", category="Actions"
|
70
76
|
),
|
77
|
+
HotkeyAction(
|
78
|
+
"toggle_ai_filter", "Toggle AI Filter", "Z", category="Actions"
|
79
|
+
),
|
71
80
|
# Segments
|
72
81
|
HotkeyAction(
|
73
82
|
"merge_segments", "Merge Selected Segments", "M", category="Segments"
|
@@ -84,6 +93,10 @@ class HotkeyManager:
|
|
84
93
|
HotkeyAction(
|
85
94
|
"select_all", "Select All Segments", "Ctrl+A", category="Segments"
|
86
95
|
),
|
96
|
+
# Classes
|
97
|
+
HotkeyAction(
|
98
|
+
"toggle_recent_class", "Toggle Recent Class", "X", category="Classes"
|
99
|
+
),
|
87
100
|
# View
|
88
101
|
HotkeyAction("zoom_in", "Zoom In", "Ctrl+Plus", category="View"),
|
89
102
|
HotkeyAction("zoom_out", "Zoom Out", "Ctrl+Minus", category="View"),
|
lazylabel/config/settings.py
CHANGED
@@ -45,6 +45,10 @@ class Settings:
|
|
45
45
|
# Multi-view Settings
|
46
46
|
multi_view_grid_mode: str = "2_view" # "2_view" or "4_view"
|
47
47
|
|
48
|
+
# Pixel Priority Settings
|
49
|
+
pixel_priority_enabled: bool = False
|
50
|
+
pixel_priority_ascending: bool = True
|
51
|
+
|
48
52
|
def save_to_file(self, filepath: str) -> None:
|
49
53
|
"""Save settings to JSON file."""
|
50
54
|
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
lazylabel/core/file_manager.py
CHANGED
@@ -22,6 +22,8 @@ class FileManager:
|
|
22
22
|
image_size: tuple[int, int],
|
23
23
|
class_order: list[int],
|
24
24
|
crop_coords: tuple[int, int, int, int] | None = None,
|
25
|
+
pixel_priority_enabled: bool = False,
|
26
|
+
pixel_priority_ascending: bool = True,
|
25
27
|
) -> str:
|
26
28
|
"""Save segments as NPZ file."""
|
27
29
|
logger.debug(f"Saving NPZ for image: {image_path}")
|
@@ -32,7 +34,7 @@ class FileManager:
|
|
32
34
|
raise ValueError("No classes defined for saving")
|
33
35
|
|
34
36
|
final_mask_tensor = self.segment_manager.create_final_mask_tensor(
|
35
|
-
image_size, class_order
|
37
|
+
image_size, class_order, pixel_priority_enabled, pixel_priority_ascending
|
36
38
|
)
|
37
39
|
|
38
40
|
# Validate mask tensor
|
@@ -75,10 +77,12 @@ class FileManager:
|
|
75
77
|
class_order: list[int],
|
76
78
|
class_labels: list[str],
|
77
79
|
crop_coords: tuple[int, int, int, int] | None = None,
|
80
|
+
pixel_priority_enabled: bool = False,
|
81
|
+
pixel_priority_ascending: bool = True,
|
78
82
|
) -> str | None:
|
79
83
|
"""Save segments as YOLO format TXT file."""
|
80
84
|
final_mask_tensor = self.segment_manager.create_final_mask_tensor(
|
81
|
-
image_size, class_order
|
85
|
+
image_size, class_order, pixel_priority_enabled, pixel_priority_ascending
|
82
86
|
)
|
83
87
|
|
84
88
|
# Apply crop if coordinates are provided
|
@@ -15,6 +15,7 @@ class SegmentManager:
|
|
15
15
|
self.class_aliases: dict[int, str] = {}
|
16
16
|
self.next_class_id: int = 0
|
17
17
|
self.active_class_id: int | None = None # Currently active/toggled class
|
18
|
+
self.last_toggled_class_id: int | None = None # Most recently toggled class
|
18
19
|
|
19
20
|
def clear(self) -> None:
|
20
21
|
"""Clear all segments and reset state."""
|
@@ -22,6 +23,7 @@ class SegmentManager:
|
|
22
23
|
self.class_aliases.clear()
|
23
24
|
self.next_class_id = 0
|
24
25
|
self.active_class_id = None
|
26
|
+
self.last_toggled_class_id = None
|
25
27
|
|
26
28
|
def add_segment(self, segment_data: dict[str, Any]) -> None:
|
27
29
|
"""Add a new segment.
|
@@ -36,6 +38,9 @@ class SegmentManager:
|
|
36
38
|
else:
|
37
39
|
segment_data["class_id"] = self.next_class_id
|
38
40
|
|
41
|
+
# Track the class used for this segment as the most recently used
|
42
|
+
self.last_toggled_class_id = segment_data["class_id"]
|
43
|
+
|
39
44
|
# Convert QPointF to list for storage if it's a polygon and contains QPointF objects
|
40
45
|
if (
|
41
46
|
segment_data.get("type") == "Polygon"
|
@@ -103,7 +108,11 @@ class SegmentManager:
|
|
103
108
|
return mask.astype(bool)
|
104
109
|
|
105
110
|
def create_final_mask_tensor(
|
106
|
-
self,
|
111
|
+
self,
|
112
|
+
image_size: tuple[int, int],
|
113
|
+
class_order: list[int],
|
114
|
+
pixel_priority_enabled: bool = False,
|
115
|
+
pixel_priority_ascending: bool = True,
|
107
116
|
) -> np.ndarray:
|
108
117
|
"""Create final mask tensor for saving."""
|
109
118
|
h, w = image_size
|
@@ -130,8 +139,59 @@ class SegmentManager:
|
|
130
139
|
final_mask_tensor[:, :, new_channel_idx], mask
|
131
140
|
)
|
132
141
|
|
142
|
+
# Apply pixel priority if enabled
|
143
|
+
if pixel_priority_enabled:
|
144
|
+
final_mask_tensor = self._apply_pixel_priority(
|
145
|
+
final_mask_tensor, pixel_priority_ascending
|
146
|
+
)
|
147
|
+
|
133
148
|
return final_mask_tensor
|
134
149
|
|
150
|
+
def _apply_pixel_priority(
|
151
|
+
self, mask_tensor: np.ndarray, ascending: bool
|
152
|
+
) -> np.ndarray:
|
153
|
+
"""Apply pixel priority to mask tensor so only one class can occupy each pixel.
|
154
|
+
|
155
|
+
Args:
|
156
|
+
mask_tensor: 3D array of shape (height, width, num_classes)
|
157
|
+
ascending: If True, lower class indices have priority; if False, higher indices have priority
|
158
|
+
|
159
|
+
Returns:
|
160
|
+
Modified mask tensor with pixel priority applied
|
161
|
+
"""
|
162
|
+
# Create a copy to avoid modifying the original
|
163
|
+
prioritized_mask = mask_tensor.copy()
|
164
|
+
|
165
|
+
# Find pixels with multiple class overlaps (sum > 1 across class dimension)
|
166
|
+
overlap_pixels = np.sum(mask_tensor, axis=2) > 1
|
167
|
+
|
168
|
+
if not np.any(overlap_pixels):
|
169
|
+
# No overlapping pixels, return original
|
170
|
+
return prioritized_mask
|
171
|
+
|
172
|
+
# Get coordinates of overlapping pixels
|
173
|
+
overlap_coords = np.where(overlap_pixels)
|
174
|
+
|
175
|
+
for y, x in zip(overlap_coords[0], overlap_coords[1], strict=False):
|
176
|
+
# Get all classes present at this pixel
|
177
|
+
classes_at_pixel = np.where(mask_tensor[y, x, :] > 0)[0]
|
178
|
+
|
179
|
+
if len(classes_at_pixel) <= 1:
|
180
|
+
continue # No overlap, skip
|
181
|
+
|
182
|
+
# Determine priority class based on ascending/descending setting
|
183
|
+
if ascending:
|
184
|
+
priority_class = np.min(classes_at_pixel) # Lowest index has priority
|
185
|
+
else:
|
186
|
+
priority_class = np.max(classes_at_pixel) # Highest index has priority
|
187
|
+
|
188
|
+
# Set all classes to 0 at this pixel
|
189
|
+
prioritized_mask[y, x, :] = 0
|
190
|
+
# Set only the priority class to 1
|
191
|
+
prioritized_mask[y, x, priority_class] = 1
|
192
|
+
|
193
|
+
return prioritized_mask
|
194
|
+
|
135
195
|
def reassign_class_ids(self, new_order: list[int]) -> None:
|
136
196
|
"""Reassign class IDs based on new order."""
|
137
197
|
id_map = {old_id: new_id for new_id, old_id in enumerate(new_order)}
|
@@ -168,6 +228,9 @@ class SegmentManager:
|
|
168
228
|
|
169
229
|
def toggle_active_class(self, class_id: int) -> bool:
|
170
230
|
"""Toggle a class as active. Returns True if now active, False if deactivated."""
|
231
|
+
# Track this as the last toggled class
|
232
|
+
self.last_toggled_class_id = class_id
|
233
|
+
|
171
234
|
if self.active_class_id == class_id:
|
172
235
|
self.active_class_id = None
|
173
236
|
return False
|
@@ -175,6 +238,175 @@ class SegmentManager:
|
|
175
238
|
self.active_class_id = class_id
|
176
239
|
return True
|
177
240
|
|
241
|
+
def get_last_toggled_class(self) -> int | None:
|
242
|
+
"""Get the most recently toggled class ID."""
|
243
|
+
return self.last_toggled_class_id
|
244
|
+
|
245
|
+
def get_class_to_toggle_with_hotkey(self) -> int | None:
|
246
|
+
"""Get the class ID to toggle when using the hotkey.
|
247
|
+
|
248
|
+
Returns the most recent class used/toggled, or if no recent class
|
249
|
+
was used then returns the last class in the class list.
|
250
|
+
"""
|
251
|
+
# If we have a recently toggled class, use that
|
252
|
+
if self.last_toggled_class_id is not None:
|
253
|
+
return self.last_toggled_class_id
|
254
|
+
|
255
|
+
# Otherwise, get the last class in the class list (highest ID)
|
256
|
+
unique_class_ids = self.get_unique_class_ids()
|
257
|
+
if unique_class_ids:
|
258
|
+
return unique_class_ids[-1] # Last (highest) class ID
|
259
|
+
|
260
|
+
return None
|
261
|
+
|
262
|
+
def erase_segments_with_shape(
|
263
|
+
self, erase_vertices: list[QPointF], image_size: tuple[int, int]
|
264
|
+
) -> list[int]:
|
265
|
+
"""Erase segments that overlap with the given shape.
|
266
|
+
|
267
|
+
Args:
|
268
|
+
erase_vertices: Vertices of the erase shape
|
269
|
+
image_size: Size of the image (height, width)
|
270
|
+
|
271
|
+
Returns:
|
272
|
+
List of indices of segments that were removed
|
273
|
+
"""
|
274
|
+
if not erase_vertices or not self.segments:
|
275
|
+
return [], []
|
276
|
+
|
277
|
+
# Create mask from erase shape
|
278
|
+
erase_mask = self.rasterize_polygon(erase_vertices, image_size)
|
279
|
+
if erase_mask is None:
|
280
|
+
return [], []
|
281
|
+
|
282
|
+
return self.erase_segments_with_mask(erase_mask, image_size)
|
283
|
+
|
284
|
+
def erase_segments_with_mask(
|
285
|
+
self, erase_mask: np.ndarray, image_size: tuple[int, int]
|
286
|
+
) -> list[int]:
|
287
|
+
"""Erase overlapping pixels from segments that overlap with the given mask.
|
288
|
+
|
289
|
+
Args:
|
290
|
+
erase_mask: Binary mask indicating pixels to erase
|
291
|
+
image_size: Size of the image (height, width)
|
292
|
+
|
293
|
+
Returns:
|
294
|
+
List of indices of segments that were modified or removed
|
295
|
+
"""
|
296
|
+
if erase_mask is None or not self.segments:
|
297
|
+
return [], []
|
298
|
+
|
299
|
+
modified_indices = []
|
300
|
+
segments_to_remove = []
|
301
|
+
segments_to_add = []
|
302
|
+
removed_segments_data = [] # Store segment data for undo
|
303
|
+
|
304
|
+
# Iterate through all segments to find overlaps
|
305
|
+
for i, segment in enumerate(self.segments):
|
306
|
+
segment_mask = self._get_segment_mask(segment, image_size)
|
307
|
+
if segment_mask is None:
|
308
|
+
continue
|
309
|
+
|
310
|
+
# Check if there's any overlap between erase mask and segment mask
|
311
|
+
overlap = np.logical_and(erase_mask, segment_mask)
|
312
|
+
overlap_area = np.sum(overlap)
|
313
|
+
|
314
|
+
if overlap_area > 0:
|
315
|
+
modified_indices.append(i)
|
316
|
+
|
317
|
+
# Store original segment data before modification
|
318
|
+
removed_segments_data.append({"index": i, "segment": segment.copy()})
|
319
|
+
|
320
|
+
# Create new mask by removing erased pixels
|
321
|
+
new_mask = np.logical_and(segment_mask, ~erase_mask)
|
322
|
+
remaining_area = np.sum(new_mask)
|
323
|
+
|
324
|
+
if remaining_area == 0:
|
325
|
+
# All pixels were erased, remove segment entirely
|
326
|
+
segments_to_remove.append(i)
|
327
|
+
else:
|
328
|
+
# Some pixels remain, check for disconnected components
|
329
|
+
# Use connected component analysis to split into separate segments
|
330
|
+
connected_segments = self._split_mask_into_components(
|
331
|
+
new_mask, segment.get("class_id")
|
332
|
+
)
|
333
|
+
|
334
|
+
# Mark original for removal and add all connected components
|
335
|
+
segments_to_remove.append(i)
|
336
|
+
segments_to_add.extend(connected_segments)
|
337
|
+
|
338
|
+
# Remove segments in reverse order to maintain indices
|
339
|
+
for i in sorted(segments_to_remove, reverse=True):
|
340
|
+
del self.segments[i]
|
341
|
+
|
342
|
+
# Add new segments (modified versions)
|
343
|
+
for new_segment in segments_to_add:
|
344
|
+
self.segments.append(new_segment)
|
345
|
+
|
346
|
+
if modified_indices:
|
347
|
+
self._update_next_class_id()
|
348
|
+
|
349
|
+
return modified_indices, removed_segments_data
|
350
|
+
|
351
|
+
def _get_segment_mask(
|
352
|
+
self, segment: dict[str, Any], image_size: tuple[int, int]
|
353
|
+
) -> np.ndarray | None:
|
354
|
+
"""Get binary mask for a segment.
|
355
|
+
|
356
|
+
Args:
|
357
|
+
segment: Segment data
|
358
|
+
image_size: Size of the image (height, width)
|
359
|
+
|
360
|
+
Returns:
|
361
|
+
Binary mask for the segment or None if unable to create
|
362
|
+
"""
|
363
|
+
if segment["type"] == "Polygon" and "vertices" in segment:
|
364
|
+
# Convert stored list of lists back to QPointF objects for rasterization
|
365
|
+
qpoints = [QPointF(p[0], p[1]) for p in segment["vertices"]]
|
366
|
+
return self.rasterize_polygon(qpoints, image_size)
|
367
|
+
elif "mask" in segment and segment["mask"] is not None:
|
368
|
+
return segment["mask"]
|
369
|
+
return None
|
370
|
+
|
371
|
+
def _split_mask_into_components(
|
372
|
+
self, mask: np.ndarray, class_id: int | None
|
373
|
+
) -> list[dict[str, Any]]:
|
374
|
+
"""Split a mask into separate segments for each connected component.
|
375
|
+
|
376
|
+
Args:
|
377
|
+
mask: Binary mask to split
|
378
|
+
class_id: Class ID to assign to all resulting segments
|
379
|
+
|
380
|
+
Returns:
|
381
|
+
List of segment dictionaries, one for each connected component
|
382
|
+
"""
|
383
|
+
if mask is None or np.sum(mask) == 0:
|
384
|
+
return []
|
385
|
+
|
386
|
+
# Convert boolean mask to uint8 for OpenCV
|
387
|
+
mask_uint8 = mask.astype(np.uint8)
|
388
|
+
|
389
|
+
# Find connected components
|
390
|
+
num_labels, labels = cv2.connectedComponents(mask_uint8, connectivity=8)
|
391
|
+
|
392
|
+
segments = []
|
393
|
+
|
394
|
+
# Create a segment for each component (skip label 0 which is background)
|
395
|
+
for label in range(1, num_labels):
|
396
|
+
component_mask = labels == label
|
397
|
+
|
398
|
+
# Only create segment if component has significant size
|
399
|
+
if np.sum(component_mask) > 10: # Minimum 10 pixels
|
400
|
+
new_segment = {
|
401
|
+
"type": "AI", # Convert to mask-based segment
|
402
|
+
"mask": component_mask,
|
403
|
+
"vertices": None,
|
404
|
+
"class_id": class_id,
|
405
|
+
}
|
406
|
+
segments.append(new_segment)
|
407
|
+
|
408
|
+
return segments
|
409
|
+
|
178
410
|
def _update_next_class_id(self) -> None:
|
179
411
|
"""Update the next available class ID."""
|
180
412
|
all_ids = {
|
lazylabel/ui/main_window.py
CHANGED
@@ -182,6 +182,9 @@ class MainWindow(QMainWindow):
|
|
182
182
|
self.pan_multiplier = self.settings.pan_multiplier
|
183
183
|
self.polygon_join_threshold = self.settings.polygon_join_threshold
|
184
184
|
self.fragment_threshold = self.settings.fragment_threshold
|
185
|
+
self.last_ai_filter_value = (
|
186
|
+
100 if self.fragment_threshold == 0 else self.fragment_threshold
|
187
|
+
)
|
185
188
|
|
186
189
|
# Image adjustment state
|
187
190
|
self.brightness = self.settings.brightness
|
@@ -776,7 +779,9 @@ class MainWindow(QMainWindow):
|
|
776
779
|
"undo": self._undo_last_action,
|
777
780
|
"redo": self._redo_last_action,
|
778
781
|
"select_all": lambda: self.right_panel.select_all_segments(),
|
782
|
+
"toggle_recent_class": self._toggle_recent_class,
|
779
783
|
"save_segment": self._handle_space_press,
|
784
|
+
"erase_segment": self._handle_shift_space_press,
|
780
785
|
"save_output": self._handle_enter_press,
|
781
786
|
"save_output_alt": self._handle_enter_press,
|
782
787
|
"fit_view": self._handle_fit_view,
|
@@ -786,6 +791,7 @@ class MainWindow(QMainWindow):
|
|
786
791
|
"pan_down": lambda: self._handle_pan_key("down"),
|
787
792
|
"pan_left": lambda: self._handle_pan_key("left"),
|
788
793
|
"pan_right": lambda: self._handle_pan_key("right"),
|
794
|
+
"toggle_ai_filter": self._toggle_ai_filter,
|
789
795
|
}
|
790
796
|
|
791
797
|
# Create shortcuts for each action
|
@@ -1054,6 +1060,8 @@ class MainWindow(QMainWindow):
|
|
1054
1060
|
|
1055
1061
|
def _set_fragment_threshold(self, value):
|
1056
1062
|
"""Set fragment threshold for AI segment filtering."""
|
1063
|
+
if value > 0:
|
1064
|
+
self.last_ai_filter_value = value
|
1057
1065
|
self.fragment_threshold = value
|
1058
1066
|
self.settings.fragment_threshold = value
|
1059
1067
|
|
@@ -2524,7 +2532,7 @@ class MainWindow(QMainWindow):
|
|
2524
2532
|
|
2525
2533
|
if self.mode == "polygon":
|
2526
2534
|
if self.view_mode == "single" and self.polygon_points:
|
2527
|
-
self._finalize_polygon()
|
2535
|
+
self._finalize_polygon(erase_mode=False)
|
2528
2536
|
elif self.view_mode == "multi" and hasattr(
|
2529
2537
|
self, "multi_view_polygon_points"
|
2530
2538
|
):
|
@@ -2536,12 +2544,65 @@ class MainWindow(QMainWindow):
|
|
2536
2544
|
self.multi_view_mode_handler._finalize_multi_view_polygon(i)
|
2537
2545
|
else:
|
2538
2546
|
self._finalize_multi_view_polygon(i)
|
2547
|
+
elif self.mode == "ai":
|
2548
|
+
# For AI mode, use accept method (normal mode)
|
2549
|
+
if self.view_mode == "single":
|
2550
|
+
self._accept_ai_segment(erase_mode=False)
|
2551
|
+
elif self.view_mode == "multi":
|
2552
|
+
# Handle multi-view AI acceptance (normal mode)
|
2553
|
+
if (
|
2554
|
+
hasattr(self, "multi_view_mode_handler")
|
2555
|
+
and self.multi_view_mode_handler
|
2556
|
+
):
|
2557
|
+
self.multi_view_mode_handler.save_ai_predictions()
|
2558
|
+
self._show_notification("AI segment(s) accepted")
|
2559
|
+
else:
|
2560
|
+
self._accept_ai_segment(erase_mode=False)
|
2539
2561
|
else:
|
2540
|
-
# For
|
2541
|
-
|
2542
|
-
|
2543
|
-
|
2544
|
-
|
2562
|
+
# For other modes, save current segment
|
2563
|
+
self._save_current_segment()
|
2564
|
+
|
2565
|
+
def _handle_shift_space_press(self):
|
2566
|
+
"""Handle Shift+Space key press for erase functionality."""
|
2567
|
+
logger.debug(
|
2568
|
+
f"Shift+Space pressed - mode: {self.mode}, view_mode: {self.view_mode}"
|
2569
|
+
)
|
2570
|
+
|
2571
|
+
if self.mode == "polygon":
|
2572
|
+
if self.view_mode == "single" and self.polygon_points:
|
2573
|
+
self._finalize_polygon(erase_mode=True)
|
2574
|
+
elif self.view_mode == "multi" and hasattr(
|
2575
|
+
self, "multi_view_polygon_points"
|
2576
|
+
):
|
2577
|
+
# Complete polygons for all viewers that have points
|
2578
|
+
for i, points in enumerate(self.multi_view_polygon_points):
|
2579
|
+
if points and len(points) >= 3:
|
2580
|
+
# Use the multi-view mode handler for proper pairing logic
|
2581
|
+
if hasattr(self, "multi_view_mode_handler"):
|
2582
|
+
self.multi_view_mode_handler._finalize_multi_view_polygon(i)
|
2583
|
+
else:
|
2584
|
+
self._finalize_multi_view_polygon(i)
|
2585
|
+
elif self.mode == "ai":
|
2586
|
+
# For AI mode, use accept method with erase mode
|
2587
|
+
if self.view_mode == "single":
|
2588
|
+
self._accept_ai_segment(erase_mode=True)
|
2589
|
+
elif self.view_mode == "multi":
|
2590
|
+
# Handle multi-view AI acceptance with erase mode
|
2591
|
+
if (
|
2592
|
+
hasattr(self, "multi_view_mode_handler")
|
2593
|
+
and self.multi_view_mode_handler
|
2594
|
+
):
|
2595
|
+
logger.debug(
|
2596
|
+
"Shift+Space pressed in multi-view AI mode - erase not yet implemented"
|
2597
|
+
)
|
2598
|
+
self._show_notification(
|
2599
|
+
"Eraser not yet implemented for multi-view AI mode"
|
2600
|
+
)
|
2601
|
+
else:
|
2602
|
+
self._accept_ai_segment(erase_mode=True)
|
2603
|
+
else:
|
2604
|
+
# For other modes, show notification that erase isn't available
|
2605
|
+
self._show_notification(f"Erase mode not available in {self.mode} mode")
|
2545
2606
|
|
2546
2607
|
def _handle_enter_press(self):
|
2547
2608
|
"""Handle enter key press."""
|
@@ -2721,6 +2782,13 @@ class MainWindow(QMainWindow):
|
|
2721
2782
|
"All segments filtered out by fragment threshold"
|
2722
2783
|
)
|
2723
2784
|
|
2785
|
+
def _toggle_ai_filter(self):
|
2786
|
+
"""Toggle AI filter between 0 and last set value."""
|
2787
|
+
new_value = self.last_ai_filter_value if self.fragment_threshold == 0 else 0
|
2788
|
+
|
2789
|
+
# Update the control panel widget
|
2790
|
+
self.control_panel.set_fragment_threshold(new_value)
|
2791
|
+
|
2724
2792
|
def _apply_fragment_threshold(self, mask):
|
2725
2793
|
"""Apply fragment threshold filtering to remove small segments."""
|
2726
2794
|
if self.fragment_threshold == 0:
|
@@ -2765,24 +2833,51 @@ class MainWindow(QMainWindow):
|
|
2765
2833
|
# Convert back to boolean mask
|
2766
2834
|
return (filtered_mask > 0).astype(bool)
|
2767
2835
|
|
2768
|
-
def _finalize_polygon(self):
|
2836
|
+
def _finalize_polygon(self, erase_mode=False):
|
2769
2837
|
"""Finalize polygon drawing."""
|
2770
2838
|
if len(self.polygon_points) < 3:
|
2771
2839
|
return
|
2772
2840
|
|
2773
|
-
|
2774
|
-
|
2775
|
-
|
2776
|
-
|
2777
|
-
|
2778
|
-
|
2779
|
-
|
2780
|
-
|
2781
|
-
|
2782
|
-
|
2783
|
-
|
2841
|
+
if erase_mode:
|
2842
|
+
# Erase overlapping segments using polygon vertices
|
2843
|
+
image_height = self.viewer._pixmap_item.pixmap().height()
|
2844
|
+
image_width = self.viewer._pixmap_item.pixmap().width()
|
2845
|
+
image_size = (image_height, image_width)
|
2846
|
+
removed_indices, removed_segments_data = (
|
2847
|
+
self.segment_manager.erase_segments_with_shape(
|
2848
|
+
self.polygon_points, image_size
|
2849
|
+
)
|
2850
|
+
)
|
2851
|
+
|
2852
|
+
if removed_indices:
|
2853
|
+
# Record the action for undo
|
2854
|
+
self.action_history.append(
|
2855
|
+
{
|
2856
|
+
"type": "erase_segments",
|
2857
|
+
"removed_segments": removed_segments_data,
|
2858
|
+
}
|
2859
|
+
)
|
2860
|
+
self._show_notification(
|
2861
|
+
f"Applied eraser to {len(removed_indices)} segment(s)"
|
2862
|
+
)
|
2863
|
+
else:
|
2864
|
+
self._show_notification("No segments to erase")
|
2865
|
+
else:
|
2866
|
+
# Create new polygon segment (normal mode)
|
2867
|
+
new_segment = {
|
2868
|
+
"vertices": [[p.x(), p.y()] for p in self.polygon_points],
|
2869
|
+
"type": "Polygon",
|
2870
|
+
"mask": None,
|
2784
2871
|
}
|
2785
|
-
|
2872
|
+
self.segment_manager.add_segment(new_segment)
|
2873
|
+
# Record the action for undo
|
2874
|
+
self.action_history.append(
|
2875
|
+
{
|
2876
|
+
"type": "add_segment",
|
2877
|
+
"segment_index": len(self.segment_manager.segments) - 1,
|
2878
|
+
}
|
2879
|
+
)
|
2880
|
+
|
2786
2881
|
# Clear redo history when a new action is performed
|
2787
2882
|
self.redo_history.clear()
|
2788
2883
|
|
@@ -2881,6 +2976,8 @@ class MainWindow(QMainWindow):
|
|
2881
2976
|
(h, w),
|
2882
2977
|
class_order,
|
2883
2978
|
crop_coords, # Pass crop coordinates if available
|
2979
|
+
settings.get("pixel_priority_enabled", False),
|
2980
|
+
settings.get("pixel_priority_ascending", True),
|
2884
2981
|
)
|
2885
2982
|
saved_files.append(os.path.basename(npz_path))
|
2886
2983
|
# Track saved file for highlighting later
|
@@ -2915,6 +3012,8 @@ class MainWindow(QMainWindow):
|
|
2915
3012
|
class_order,
|
2916
3013
|
class_labels,
|
2917
3014
|
crop_coords, # Pass crop coordinates if available
|
3015
|
+
settings.get("pixel_priority_enabled", False),
|
3016
|
+
settings.get("pixel_priority_ascending", True),
|
2918
3017
|
)
|
2919
3018
|
saved_files.append(os.path.basename(txt_path))
|
2920
3019
|
# Track saved file for highlighting later
|
@@ -3058,6 +3157,8 @@ class MainWindow(QMainWindow):
|
|
3058
3157
|
(h, w),
|
3059
3158
|
class_order,
|
3060
3159
|
self.current_crop_coords,
|
3160
|
+
settings.get("pixel_priority_enabled", False),
|
3161
|
+
settings.get("pixel_priority_ascending", True),
|
3061
3162
|
)
|
3062
3163
|
logger.debug(f"NPZ save completed: {npz_path}")
|
3063
3164
|
self._show_success_notification(
|
@@ -3087,6 +3188,8 @@ class MainWindow(QMainWindow):
|
|
3087
3188
|
class_order,
|
3088
3189
|
class_labels,
|
3089
3190
|
self.current_crop_coords,
|
3191
|
+
settings.get("pixel_priority_enabled", False),
|
3192
|
+
settings.get("pixel_priority_ascending", True),
|
3090
3193
|
)
|
3091
3194
|
if txt_path:
|
3092
3195
|
logger.debug(f"TXT save completed: {txt_path}")
|
@@ -3528,9 +3631,11 @@ class MainWindow(QMainWindow):
|
|
3528
3631
|
"""Clear notification from status bar."""
|
3529
3632
|
self.status_bar.clear_message()
|
3530
3633
|
|
3531
|
-
def _accept_ai_segment(self):
|
3634
|
+
def _accept_ai_segment(self, erase_mode=False):
|
3532
3635
|
"""Accept the current AI segment preview (spacebar handler)."""
|
3533
|
-
logger.debug(
|
3636
|
+
logger.debug(
|
3637
|
+
f"_accept_ai_segment called - view_mode: {self.view_mode}, erase_mode: {erase_mode}"
|
3638
|
+
)
|
3534
3639
|
|
3535
3640
|
if self.view_mode == "single":
|
3536
3641
|
# Single view mode - check for preview mask (both point-based and bbox)
|
@@ -3547,7 +3652,7 @@ class MainWindow(QMainWindow):
|
|
3547
3652
|
)
|
3548
3653
|
|
3549
3654
|
logger.debug(
|
3550
|
-
f"Single view - has_preview_item: {has_preview_item}, has_preview_mask: {has_preview_mask}, has_bbox_preview: {has_bbox_preview}"
|
3655
|
+
f"Single view - has_preview_item: {has_preview_item}, has_preview_mask: {has_preview_mask}, has_bbox_preview: {has_bbox_preview}, erase_mode: {erase_mode}"
|
3551
3656
|
)
|
3552
3657
|
|
3553
3658
|
# Handle bbox preview first
|
@@ -3556,21 +3661,56 @@ class MainWindow(QMainWindow):
|
|
3556
3661
|
self.ai_bbox_preview_mask
|
3557
3662
|
)
|
3558
3663
|
if filtered_mask is not None:
|
3559
|
-
|
3560
|
-
|
3561
|
-
|
3562
|
-
|
3563
|
-
|
3564
|
-
|
3565
|
-
|
3664
|
+
if erase_mode:
|
3665
|
+
# Erase overlapping segments
|
3666
|
+
logger.debug(
|
3667
|
+
"Erase mode active for bbox preview - applying eraser to AI segment"
|
3668
|
+
)
|
3669
|
+
image_height = self.viewer._pixmap_item.pixmap().height()
|
3670
|
+
image_width = self.viewer._pixmap_item.pixmap().width()
|
3671
|
+
image_size = (image_height, image_width)
|
3672
|
+
removed_indices, removed_segments_data = (
|
3673
|
+
self.segment_manager.erase_segments_with_mask(
|
3674
|
+
filtered_mask, image_size
|
3675
|
+
)
|
3676
|
+
)
|
3677
|
+
logger.debug(
|
3678
|
+
f"Bbox erase operation completed - modified {len(removed_indices)} segments"
|
3679
|
+
)
|
3566
3680
|
|
3567
|
-
|
3568
|
-
|
3569
|
-
|
3570
|
-
|
3571
|
-
|
3681
|
+
if removed_indices:
|
3682
|
+
# Record the action for undo
|
3683
|
+
self.action_history.append(
|
3684
|
+
{
|
3685
|
+
"type": "erase_segments",
|
3686
|
+
"removed_segments": removed_segments_data,
|
3687
|
+
}
|
3688
|
+
)
|
3689
|
+
self._show_success_notification(
|
3690
|
+
f"Erased {len(removed_indices)} segment(s)!"
|
3691
|
+
)
|
3692
|
+
else:
|
3693
|
+
self._show_notification("No segments to erase")
|
3694
|
+
else:
|
3695
|
+
# Create actual segment (normal mode)
|
3696
|
+
new_segment = {
|
3697
|
+
"type": "AI",
|
3698
|
+
"mask": filtered_mask,
|
3699
|
+
"vertices": None,
|
3572
3700
|
}
|
3573
|
-
|
3701
|
+
self.segment_manager.add_segment(new_segment)
|
3702
|
+
|
3703
|
+
# Record the action for undo
|
3704
|
+
self.action_history.append(
|
3705
|
+
{
|
3706
|
+
"type": "add_segment",
|
3707
|
+
"segment_index": len(self.segment_manager.segments) - 1,
|
3708
|
+
}
|
3709
|
+
)
|
3710
|
+
self._show_success_notification(
|
3711
|
+
"AI bounding box segment saved!"
|
3712
|
+
)
|
3713
|
+
|
3574
3714
|
# Clear redo history when a new action is performed
|
3575
3715
|
self.redo_history.clear()
|
3576
3716
|
|
@@ -3586,7 +3726,6 @@ class MainWindow(QMainWindow):
|
|
3586
3726
|
# Clear all points
|
3587
3727
|
self.clear_all_points()
|
3588
3728
|
self._update_all_lists()
|
3589
|
-
self._show_success_notification("AI bounding box segment saved!")
|
3590
3729
|
else:
|
3591
3730
|
self._show_warning_notification(
|
3592
3731
|
"All segments filtered out by fragment threshold"
|
@@ -3597,28 +3736,60 @@ class MainWindow(QMainWindow):
|
|
3597
3736
|
self.current_preview_mask
|
3598
3737
|
)
|
3599
3738
|
if filtered_mask is not None:
|
3600
|
-
|
3601
|
-
|
3602
|
-
|
3603
|
-
|
3604
|
-
|
3605
|
-
|
3606
|
-
|
3739
|
+
if erase_mode:
|
3740
|
+
# Erase overlapping segments
|
3741
|
+
logger.debug(
|
3742
|
+
"Erase mode active for regular AI preview - applying eraser to AI segment"
|
3743
|
+
)
|
3744
|
+
image_height = self.viewer._pixmap_item.pixmap().height()
|
3745
|
+
image_width = self.viewer._pixmap_item.pixmap().width()
|
3746
|
+
image_size = (image_height, image_width)
|
3747
|
+
removed_indices, removed_segments_data = (
|
3748
|
+
self.segment_manager.erase_segments_with_mask(
|
3749
|
+
filtered_mask, image_size
|
3750
|
+
)
|
3751
|
+
)
|
3752
|
+
logger.debug(
|
3753
|
+
f"Regular AI erase operation completed - modified {len(removed_indices)} segments"
|
3754
|
+
)
|
3607
3755
|
|
3608
|
-
|
3609
|
-
|
3610
|
-
|
3611
|
-
|
3612
|
-
|
3756
|
+
if removed_indices:
|
3757
|
+
# Record the action for undo
|
3758
|
+
self.action_history.append(
|
3759
|
+
{
|
3760
|
+
"type": "erase_segments",
|
3761
|
+
"removed_segments": removed_segments_data,
|
3762
|
+
}
|
3763
|
+
)
|
3764
|
+
self._show_notification(
|
3765
|
+
f"Applied eraser to {len(removed_indices)} segment(s)"
|
3766
|
+
)
|
3767
|
+
else:
|
3768
|
+
self._show_notification("No segments to erase")
|
3769
|
+
else:
|
3770
|
+
# Create actual segment (normal mode)
|
3771
|
+
new_segment = {
|
3772
|
+
"type": "AI",
|
3773
|
+
"mask": filtered_mask,
|
3774
|
+
"vertices": None,
|
3613
3775
|
}
|
3614
|
-
|
3776
|
+
self.segment_manager.add_segment(new_segment)
|
3777
|
+
|
3778
|
+
# Record the action for undo
|
3779
|
+
self.action_history.append(
|
3780
|
+
{
|
3781
|
+
"type": "add_segment",
|
3782
|
+
"segment_index": len(self.segment_manager.segments) - 1,
|
3783
|
+
}
|
3784
|
+
)
|
3785
|
+
self._show_notification("AI segment accepted")
|
3786
|
+
|
3615
3787
|
# Clear redo history when a new action is performed
|
3616
3788
|
self.redo_history.clear()
|
3617
3789
|
|
3618
3790
|
# Clear all points after accepting
|
3619
3791
|
self.clear_all_points()
|
3620
3792
|
self._update_all_lists()
|
3621
|
-
self._show_notification("AI segment accepted")
|
3622
3793
|
else:
|
3623
3794
|
self._show_warning_notification(
|
3624
3795
|
"All segments filtered out by fragment threshold"
|
@@ -3628,6 +3799,18 @@ class MainWindow(QMainWindow):
|
|
3628
3799
|
self.viewer.scene().removeItem(self.preview_mask_item)
|
3629
3800
|
self.preview_mask_item = None
|
3630
3801
|
self.current_preview_mask = None
|
3802
|
+
else:
|
3803
|
+
# No AI preview found in single view
|
3804
|
+
if erase_mode:
|
3805
|
+
logger.debug(
|
3806
|
+
"No AI segment preview to erase - no preview mask found in single view"
|
3807
|
+
)
|
3808
|
+
self._show_notification("No AI segment preview to erase")
|
3809
|
+
else:
|
3810
|
+
logger.debug(
|
3811
|
+
"No AI segment preview to accept - no preview mask found in single view"
|
3812
|
+
)
|
3813
|
+
self._show_notification("No AI segment preview to accept")
|
3631
3814
|
|
3632
3815
|
elif self.view_mode == "multi":
|
3633
3816
|
# Multi-view mode - use the multi-view mode handler to save AI predictions
|
@@ -3707,7 +3890,13 @@ class MainWindow(QMainWindow):
|
|
3707
3890
|
self._show_success_notification("AI segment(s) accepted")
|
3708
3891
|
self._update_all_lists()
|
3709
3892
|
else:
|
3710
|
-
|
3893
|
+
if erase_mode:
|
3894
|
+
logger.debug(
|
3895
|
+
"No AI segment preview to erase - no preview mask found"
|
3896
|
+
)
|
3897
|
+
self._show_notification("No AI segment preview to erase")
|
3898
|
+
else:
|
3899
|
+
self._show_notification("No AI segment preview to accept")
|
3711
3900
|
|
3712
3901
|
def _show_hotkey_dialog(self):
|
3713
3902
|
"""Show the hotkey configuration dialog."""
|
@@ -4068,28 +4257,64 @@ class MainWindow(QMainWindow):
|
|
4068
4257
|
self.drag_start_pos = None
|
4069
4258
|
|
4070
4259
|
if rect.width() >= 2 and rect.height() >= 2:
|
4071
|
-
#
|
4260
|
+
# Check if shift is pressed for erase functionality
|
4261
|
+
modifiers = QApplication.keyboardModifiers()
|
4262
|
+
shift_pressed = bool(modifiers & Qt.KeyboardModifier.ShiftModifier)
|
4263
|
+
|
4264
|
+
if shift_pressed:
|
4265
|
+
logger.debug("Shift+bbox release - activating erase mode")
|
4266
|
+
|
4267
|
+
# Convert QRectF to QPolygonF for vertex representation
|
4072
4268
|
polygon = QPolygonF()
|
4073
4269
|
polygon.append(rect.topLeft())
|
4074
4270
|
polygon.append(rect.topRight())
|
4075
4271
|
polygon.append(rect.bottomRight())
|
4076
4272
|
polygon.append(rect.bottomLeft())
|
4077
4273
|
|
4078
|
-
|
4079
|
-
"vertices": [[p.x(), p.y()] for p in list(polygon)],
|
4080
|
-
"type": "Polygon", # Bounding boxes are stored as polygons
|
4081
|
-
"mask": None,
|
4082
|
-
}
|
4274
|
+
polygon_vertices = [QPointF(p.x(), p.y()) for p in list(polygon)]
|
4083
4275
|
|
4084
|
-
|
4276
|
+
if shift_pressed:
|
4277
|
+
# Erase overlapping segments using bbox vertices
|
4278
|
+
image_height = self.viewer._pixmap_item.pixmap().height()
|
4279
|
+
image_width = self.viewer._pixmap_item.pixmap().width()
|
4280
|
+
image_size = (image_height, image_width)
|
4281
|
+
removed_indices, removed_segments_data = (
|
4282
|
+
self.segment_manager.erase_segments_with_shape(
|
4283
|
+
polygon_vertices, image_size
|
4284
|
+
)
|
4285
|
+
)
|
4085
4286
|
|
4086
|
-
|
4087
|
-
|
4088
|
-
|
4089
|
-
|
4090
|
-
|
4287
|
+
if removed_indices:
|
4288
|
+
# Record the action for undo
|
4289
|
+
self.action_history.append(
|
4290
|
+
{
|
4291
|
+
"type": "erase_segments",
|
4292
|
+
"removed_segments": removed_segments_data,
|
4293
|
+
}
|
4294
|
+
)
|
4295
|
+
self._show_notification(
|
4296
|
+
f"Applied eraser to {len(removed_indices)} segment(s)"
|
4297
|
+
)
|
4298
|
+
else:
|
4299
|
+
self._show_notification("No segments to erase")
|
4300
|
+
else:
|
4301
|
+
# Create new bbox segment (normal mode)
|
4302
|
+
new_segment = {
|
4303
|
+
"vertices": [[p.x(), p.y()] for p in polygon_vertices],
|
4304
|
+
"type": "Polygon", # Bounding boxes are stored as polygons
|
4305
|
+
"mask": None,
|
4091
4306
|
}
|
4092
|
-
|
4307
|
+
|
4308
|
+
self.segment_manager.add_segment(new_segment)
|
4309
|
+
|
4310
|
+
# Record the action for undo
|
4311
|
+
self.action_history.append(
|
4312
|
+
{
|
4313
|
+
"type": "add_segment",
|
4314
|
+
"segment_index": len(self.segment_manager.segments) - 1,
|
4315
|
+
}
|
4316
|
+
)
|
4317
|
+
|
4093
4318
|
# Clear redo history when a new action is performed
|
4094
4319
|
self.redo_history.clear()
|
4095
4320
|
self._update_all_lists()
|
@@ -4313,6 +4538,10 @@ class MainWindow(QMainWindow):
|
|
4313
4538
|
|
4314
4539
|
def _handle_polygon_click(self, pos):
|
4315
4540
|
"""Handle polygon drawing clicks."""
|
4541
|
+
# Check if shift is pressed for erase functionality
|
4542
|
+
modifiers = QApplication.keyboardModifiers()
|
4543
|
+
shift_pressed = bool(modifiers & Qt.KeyboardModifier.ShiftModifier)
|
4544
|
+
|
4316
4545
|
# Check if clicking near the first point to close polygon
|
4317
4546
|
if self.polygon_points and len(self.polygon_points) > 2:
|
4318
4547
|
first_point = self.polygon_points[0]
|
@@ -4320,7 +4549,11 @@ class MainWindow(QMainWindow):
|
|
4320
4549
|
pos.y() - first_point.y()
|
4321
4550
|
) ** 2
|
4322
4551
|
if distance_squared < self.polygon_join_threshold**2:
|
4323
|
-
|
4552
|
+
if shift_pressed:
|
4553
|
+
logger.debug(
|
4554
|
+
"Shift+click polygon completion - activating erase mode"
|
4555
|
+
)
|
4556
|
+
self._finalize_polygon(erase_mode=shift_pressed)
|
4324
4557
|
return
|
4325
4558
|
|
4326
4559
|
# Add new point to polygon
|
@@ -4612,6 +4845,14 @@ class MainWindow(QMainWindow):
|
|
4612
4845
|
# Update visual display to clear active class
|
4613
4846
|
self.right_panel.update_active_class_display(None)
|
4614
4847
|
|
4848
|
+
def _toggle_recent_class(self):
|
4849
|
+
"""Toggle the most recent class used/toggled, or the last class in the list."""
|
4850
|
+
class_id = self.segment_manager.get_class_to_toggle_with_hotkey()
|
4851
|
+
if class_id is not None:
|
4852
|
+
self._handle_class_toggle(class_id)
|
4853
|
+
else:
|
4854
|
+
self._show_notification("No classes available to toggle")
|
4855
|
+
|
4615
4856
|
def _pop_out_left_panel(self):
|
4616
4857
|
"""Pop out the left control panel into a separate window."""
|
4617
4858
|
if self.left_panel_popout is not None:
|
@@ -6293,22 +6534,23 @@ class MainWindow(QMainWindow):
|
|
6293
6534
|
)
|
6294
6535
|
|
6295
6536
|
def _start_sequential_multi_view_sam_loading(self):
|
6296
|
-
"""Start loading images into SAM models
|
6537
|
+
"""Start loading images into SAM models in parallel for faster processing."""
|
6297
6538
|
# Check if any loading is already in progress to prevent duplicate workers
|
6298
6539
|
any_updating = any(self.multi_view_models_updating)
|
6299
6540
|
if any_updating:
|
6300
|
-
#
|
6541
|
+
# Loading already in progress, don't start another
|
6301
6542
|
updating_indices = [
|
6302
6543
|
i
|
6303
6544
|
for i, updating in enumerate(self.multi_view_models_updating)
|
6304
6545
|
if updating
|
6305
6546
|
]
|
6306
6547
|
logger.debug(
|
6307
|
-
f"
|
6548
|
+
f"Parallel loading already in progress for viewers: {updating_indices}"
|
6308
6549
|
)
|
6309
6550
|
return
|
6310
6551
|
|
6311
|
-
# Find
|
6552
|
+
# Find all dirty models that need updating and start them in parallel
|
6553
|
+
models_to_update = []
|
6312
6554
|
for i in range(len(self.multi_view_models)):
|
6313
6555
|
if (
|
6314
6556
|
self.multi_view_images[i]
|
@@ -6316,15 +6558,24 @@ class MainWindow(QMainWindow):
|
|
6316
6558
|
and self.multi_view_models_dirty[i]
|
6317
6559
|
and not self.multi_view_models_updating[i]
|
6318
6560
|
):
|
6319
|
-
|
6320
|
-
self._ensure_multi_view_sam_updated(i)
|
6321
|
-
return
|
6561
|
+
models_to_update.append(i)
|
6322
6562
|
|
6323
|
-
|
6324
|
-
|
6325
|
-
|
6326
|
-
|
6563
|
+
if models_to_update:
|
6564
|
+
logger.debug(f"Starting parallel loading for viewers: {models_to_update}")
|
6565
|
+
# Show notification about parallel loading
|
6566
|
+
self._show_notification(
|
6567
|
+
f"Loading embeddings for {len(models_to_update)} images in parallel...",
|
6568
|
+
duration=0,
|
6327
6569
|
)
|
6570
|
+
# Start all workers in parallel
|
6571
|
+
for i in models_to_update:
|
6572
|
+
self._ensure_multi_view_sam_updated(i)
|
6573
|
+
else:
|
6574
|
+
# If no more models to update and none are running, we're done
|
6575
|
+
if not any(self.multi_view_models_updating):
|
6576
|
+
self._show_success_notification(
|
6577
|
+
"AI models ready for prompting", duration=3000
|
6578
|
+
)
|
6328
6579
|
|
6329
6580
|
def _on_multi_view_init_error(self, error_message):
|
6330
6581
|
"""Handle multi-view model initialization error."""
|
@@ -6466,14 +6717,8 @@ class MainWindow(QMainWindow):
|
|
6466
6717
|
|
6467
6718
|
logger.debug(f"Starting SAM image loading worker for viewer {viewer_index + 1}")
|
6468
6719
|
|
6469
|
-
#
|
6470
|
-
|
6471
|
-
config = self._get_multi_view_config()
|
6472
|
-
num_viewers = config["num_viewers"]
|
6473
|
-
self._show_notification(
|
6474
|
-
f"Computing embeddings for image {viewer_index + 1}/{num_viewers}: {image_name}",
|
6475
|
-
duration=0, # Persistent until completion
|
6476
|
-
)
|
6720
|
+
# Individual notifications are now handled by the parallel loading start
|
6721
|
+
# No need for per-viewer notifications when loading in parallel
|
6477
6722
|
|
6478
6723
|
# Get current modified image if operate_on_view is enabled
|
6479
6724
|
current_image = None
|
@@ -6561,8 +6806,16 @@ class MainWindow(QMainWindow):
|
|
6561
6806
|
if hasattr(self, "_multi_view_loading_step"):
|
6562
6807
|
self._multi_view_loading_step += 1
|
6563
6808
|
|
6564
|
-
#
|
6565
|
-
self.
|
6809
|
+
# Check if all models are done (either loaded, failed, or timed out)
|
6810
|
+
if not any(self.multi_view_models_updating) and not any(
|
6811
|
+
self.multi_view_models_dirty
|
6812
|
+
):
|
6813
|
+
# All models processed
|
6814
|
+
self._show_success_notification("AI model loading complete", duration=3000)
|
6815
|
+
elif not any(self.multi_view_models_updating):
|
6816
|
+
# No models are currently updating but some may still be dirty
|
6817
|
+
# Try to load remaining models
|
6818
|
+
self._start_sequential_multi_view_sam_loading()
|
6566
6819
|
|
6567
6820
|
def _on_multi_view_sam_update_finished(self, viewer_index):
|
6568
6821
|
"""Handle completion of multi-view SAM model update."""
|
@@ -6596,14 +6849,19 @@ class MainWindow(QMainWindow):
|
|
6596
6849
|
# Update progress
|
6597
6850
|
if hasattr(self, "_multi_view_loading_step"):
|
6598
6851
|
self._multi_view_loading_step += 1
|
6599
|
-
if self._multi_view_loading_step < self._multi_view_total_steps:
|
6600
|
-
self._show_notification(
|
6601
|
-
f"Loading image {self._multi_view_loading_step + 1} of {self._multi_view_total_steps}...",
|
6602
|
-
duration=0,
|
6603
|
-
)
|
6604
6852
|
|
6605
|
-
#
|
6606
|
-
self.
|
6853
|
+
# Check if all models are done loading
|
6854
|
+
if not any(self.multi_view_models_updating) and not any(
|
6855
|
+
self.multi_view_models_dirty
|
6856
|
+
):
|
6857
|
+
# All models loaded successfully
|
6858
|
+
self._show_success_notification(
|
6859
|
+
"AI models ready for prompting", duration=3000
|
6860
|
+
)
|
6861
|
+
elif not any(self.multi_view_models_updating):
|
6862
|
+
# No models are currently updating but some may still be dirty
|
6863
|
+
# This can happen if there was an error, try to load remaining models
|
6864
|
+
self._start_sequential_multi_view_sam_loading()
|
6607
6865
|
|
6608
6866
|
def _on_multi_view_sam_update_error(self, viewer_index, error_message):
|
6609
6867
|
"""Handle multi-view SAM model update error."""
|
@@ -6639,8 +6897,16 @@ class MainWindow(QMainWindow):
|
|
6639
6897
|
if hasattr(self, "_multi_view_loading_step"):
|
6640
6898
|
self._multi_view_loading_step += 1
|
6641
6899
|
|
6642
|
-
#
|
6643
|
-
self.
|
6900
|
+
# Check if all models are done (either loaded or failed)
|
6901
|
+
if not any(self.multi_view_models_updating) and not any(
|
6902
|
+
self.multi_view_models_dirty
|
6903
|
+
):
|
6904
|
+
# All models processed (some may have failed)
|
6905
|
+
self._show_success_notification("AI model loading complete", duration=3000)
|
6906
|
+
elif not any(self.multi_view_models_updating):
|
6907
|
+
# No models are currently updating but some may still be dirty
|
6908
|
+
# Try to load remaining models
|
6909
|
+
self._start_sequential_multi_view_sam_loading()
|
6644
6910
|
|
6645
6911
|
def _cleanup_multi_view_models(self):
|
6646
6912
|
"""Clean up multi-view model instances."""
|
@@ -4,6 +4,47 @@ from PyQt6.QtCore import pyqtSignal
|
|
4
4
|
from PyQt6.QtWidgets import QCheckBox, QGroupBox, QVBoxLayout, QWidget
|
5
5
|
|
6
6
|
|
7
|
+
class PixelPriorityCheckBox(QCheckBox):
|
8
|
+
"""Custom tri-state checkbox for pixel priority settings."""
|
9
|
+
|
10
|
+
def __init__(self, parent=None):
|
11
|
+
super().__init__(parent)
|
12
|
+
self._state = 0 # 0: disabled, 1: ascending, 2: descending
|
13
|
+
self._update_display()
|
14
|
+
self.clicked.connect(self._cycle_state)
|
15
|
+
|
16
|
+
def _cycle_state(self):
|
17
|
+
"""Cycle through the three states."""
|
18
|
+
self._state = (self._state + 1) % 3
|
19
|
+
self._update_display()
|
20
|
+
|
21
|
+
def _update_display(self):
|
22
|
+
"""Update checkbox text and check state based on current state."""
|
23
|
+
if self._state == 0:
|
24
|
+
self.setText("Pixel Priority Ascending")
|
25
|
+
self.setChecked(False)
|
26
|
+
elif self._state == 1:
|
27
|
+
self.setText("Pixel Priority Ascending")
|
28
|
+
self.setChecked(True)
|
29
|
+
else: # state == 2
|
30
|
+
self.setText("Pixel Priority Descending")
|
31
|
+
self.setChecked(True)
|
32
|
+
|
33
|
+
def get_pixel_priority_settings(self):
|
34
|
+
"""Get current pixel priority settings."""
|
35
|
+
return {"enabled": self._state != 0, "ascending": self._state == 1}
|
36
|
+
|
37
|
+
def set_pixel_priority_settings(self, enabled: bool, ascending: bool):
|
38
|
+
"""Set pixel priority settings."""
|
39
|
+
if not enabled:
|
40
|
+
self._state = 0
|
41
|
+
elif ascending:
|
42
|
+
self._state = 1
|
43
|
+
else:
|
44
|
+
self._state = 2
|
45
|
+
self._update_display()
|
46
|
+
|
47
|
+
|
7
48
|
class SettingsWidget(QWidget):
|
8
49
|
"""Widget for application settings."""
|
9
50
|
|
@@ -69,6 +110,16 @@ class SettingsWidget(QWidget):
|
|
69
110
|
self.chk_operate_on_view.setChecked(False)
|
70
111
|
layout.addWidget(self.chk_operate_on_view)
|
71
112
|
|
113
|
+
# Pixel Priority
|
114
|
+
self.chk_pixel_priority = PixelPriorityCheckBox()
|
115
|
+
self.chk_pixel_priority.setToolTip(
|
116
|
+
"Control pixel ownership when multiple classes overlap.\n"
|
117
|
+
"Click to cycle through: Off → Ascending → Descending → Off\n"
|
118
|
+
"Ascending: Lower class indices take priority\n"
|
119
|
+
"Descending: Higher class indices take priority"
|
120
|
+
)
|
121
|
+
layout.addWidget(self.chk_pixel_priority)
|
122
|
+
|
72
123
|
# Main layout
|
73
124
|
main_layout = QVBoxLayout(self)
|
74
125
|
main_layout.setContentsMargins(0, 0, 0, 0)
|
@@ -90,6 +141,9 @@ class SettingsWidget(QWidget):
|
|
90
141
|
]:
|
91
142
|
checkbox.stateChanged.connect(self.settings_changed)
|
92
143
|
|
144
|
+
# Connect pixel priority checkbox (uses clicked signal instead of stateChanged)
|
145
|
+
self.chk_pixel_priority.clicked.connect(self.settings_changed)
|
146
|
+
|
93
147
|
def _handle_save_checkbox_change(self):
|
94
148
|
"""Ensure at least one save format is selected."""
|
95
149
|
is_npz_checked = self.chk_save_npz.isChecked()
|
@@ -104,6 +158,7 @@ class SettingsWidget(QWidget):
|
|
104
158
|
|
105
159
|
def get_settings(self):
|
106
160
|
"""Get current settings as dictionary."""
|
161
|
+
pixel_priority_settings = self.chk_pixel_priority.get_pixel_priority_settings()
|
107
162
|
return {
|
108
163
|
"auto_save": self.chk_auto_save.isChecked(),
|
109
164
|
"save_npz": self.chk_save_npz.isChecked(),
|
@@ -111,6 +166,8 @@ class SettingsWidget(QWidget):
|
|
111
166
|
"yolo_use_alias": self.chk_yolo_use_alias.isChecked(),
|
112
167
|
"save_class_aliases": self.chk_save_class_aliases.isChecked(),
|
113
168
|
"operate_on_view": self.chk_operate_on_view.isChecked(),
|
169
|
+
"pixel_priority_enabled": pixel_priority_settings["enabled"],
|
170
|
+
"pixel_priority_ascending": pixel_priority_settings["ascending"],
|
114
171
|
}
|
115
172
|
|
116
173
|
def set_settings(self, settings):
|
@@ -123,3 +180,7 @@ class SettingsWidget(QWidget):
|
|
123
180
|
settings.get("save_class_aliases", False)
|
124
181
|
)
|
125
182
|
self.chk_operate_on_view.setChecked(settings.get("operate_on_view", False))
|
183
|
+
self.chk_pixel_priority.set_pixel_priority_settings(
|
184
|
+
settings.get("pixel_priority_enabled", False),
|
185
|
+
settings.get("pixel_priority_ascending", True),
|
186
|
+
)
|
@@ -2,13 +2,13 @@ lazylabel/__init__.py,sha256=0yitHZYNNuF4Rqj7cqE0TrfDV8zhzKD3A5W1vOymHK4,199
|
|
2
2
|
lazylabel/__main__.py,sha256=5IWklQrNkzeMH6rchzZxxoeSnZlkz-HRiPWXqjjj2yA,139
|
3
3
|
lazylabel/main.py,sha256=g_QhEWNhgxp8LLFfxTXDMVTpdaHog_wrs5cv01xRL0Q,904
|
4
4
|
lazylabel/config/__init__.py,sha256=TnDXH0yx0zy97FfjpiVPHleMgMrM5YwWrUbvevSNJjg,263
|
5
|
-
lazylabel/config/hotkeys.py,sha256=
|
5
|
+
lazylabel/config/hotkeys.py,sha256=kHGHbtMTh_VbGQlEnfT8pmhLsu7MOqZtSFnC7shjqLA,8465
|
6
6
|
lazylabel/config/paths.py,sha256=ZVKbtaNOxmYO4l6JgsY-8DXaE_jaJfDg2RQJJn3-5nw,1275
|
7
|
-
lazylabel/config/settings.py,sha256=
|
7
|
+
lazylabel/config/settings.py,sha256=FhRF3nja_nEgulO6Qt_ToHhy5qGXwghtTTCLCL-LC48,2104
|
8
8
|
lazylabel/core/__init__.py,sha256=FmRjop_uIBSJwKMGhaZ-3Iwu34LkoxTuD-hnq5vbTSY,232
|
9
|
-
lazylabel/core/file_manager.py,sha256=
|
9
|
+
lazylabel/core/file_manager.py,sha256=bTsdBx1NzZzS9aG0f5toy9CGxp0Z_LXXBz_C_g3fgvw,7569
|
10
10
|
lazylabel/core/model_manager.py,sha256=NmgnIrw9sb_vSwRVkBr2Z_Mc2kFfdFukFgUhqs2e-L0,6829
|
11
|
-
lazylabel/core/segment_manager.py,sha256=
|
11
|
+
lazylabel/core/segment_manager.py,sha256=43_BJ4CovXAAeuEq9wkuUD54mCFPuwkxkYUXHajZug4,15204
|
12
12
|
lazylabel/models/__init__.py,sha256=fIlk_0DuZfiClcm0XlZdimeHzunQwBmTMI4PcGsaymw,91
|
13
13
|
lazylabel/models/sam2_model.py,sha256=Z0UVs_m9VwUO4znZF5VA4-HQSF-oCT9RbKtK3EU20qU,21747
|
14
14
|
lazylabel/models/sam_model.py,sha256=Q1GAaFG6n5JoZXhClcUZKN-gvA_wmtVuUYzELG7zhPg,8833
|
@@ -18,7 +18,7 @@ lazylabel/ui/editable_vertex.py,sha256=ofo3r8ZZ3b8oYV40vgzZuS3QnXYBNzE92ArC2wggJ
|
|
18
18
|
lazylabel/ui/hotkey_dialog.py,sha256=U_B76HLOxWdWkfA4d2XgRUaZTJPAAE_m5fmwf7Rh-5Y,14743
|
19
19
|
lazylabel/ui/hoverable_pixelmap_item.py,sha256=UbWVxpmCTaeae_AeA8gMOHYGUmAw40fZBFTS3sZlw48,1821
|
20
20
|
lazylabel/ui/hoverable_polygon_item.py,sha256=gZalImJ_PJYM7xON0iiSjQ335ZBREOfSscKLVs-MSh8,2314
|
21
|
-
lazylabel/ui/main_window.py,sha256=
|
21
|
+
lazylabel/ui/main_window.py,sha256=LfbGs41QoUzAj3NKYR_rRtyEF7PltB-sdBWS8ukQjfE,396744
|
22
22
|
lazylabel/ui/numeric_table_widget_item.py,sha256=dQUlIFu9syCxTGAHVIlmbgkI7aJ3f3wmDPBz1AGK9Bg,283
|
23
23
|
lazylabel/ui/photo_viewer.py,sha256=3o7Xldn9kJWvWlbpcHDRMk87dnB5xZKbfyAT3oBYlIo,6670
|
24
24
|
lazylabel/ui/reorderable_class_table.py,sha256=sxHhQre5O_MXLDFgKnw43QnvXXoqn5xRKMGitgO7muI,2371
|
@@ -34,7 +34,7 @@ lazylabel/ui/widgets/channel_threshold_widget.py,sha256=DSYDOUTMXo02Fn5P4cjh2OqD
|
|
34
34
|
lazylabel/ui/widgets/fft_threshold_widget.py,sha256=mcbkNoP0aj-Pe57sgIWBnmg4JiEbTikrwG67Vgygnio,20859
|
35
35
|
lazylabel/ui/widgets/fragment_threshold_widget.py,sha256=YtToua1eAUtEuJ3EwdCMvI-39TRrFnshkty4tnR4OMU,3492
|
36
36
|
lazylabel/ui/widgets/model_selection_widget.py,sha256=k4zyhWszi_7e-0TPa0Go1LLPZpTNGtrYSb-yT7uGiVU,8420
|
37
|
-
lazylabel/ui/widgets/settings_widget.py,sha256
|
37
|
+
lazylabel/ui/widgets/settings_widget.py,sha256=-LUIZGSQEf51fIYscJ5dzIqTsE09Rt4wGz7kAS1KnNQ,7311
|
38
38
|
lazylabel/ui/widgets/status_bar.py,sha256=wTbMQNEOBfmtNj8EVFZS_lxgaemu-CbRXeZzEQDaVz8,4014
|
39
39
|
lazylabel/ui/workers/__init__.py,sha256=1pctFFvHiVW3meiaW5_-AUgQ0AKcvCF4J8-QHoWRtwI,529
|
40
40
|
lazylabel/ui/workers/image_discovery_worker.py,sha256=mW4diqPnbyGgyX2DDKm_o9NnAq4V-Jywk_f7risVgS0,2405
|
@@ -47,9 +47,9 @@ lazylabel/utils/custom_file_system_model.py,sha256=-3EimlybvevH6bvqBE0qdFnLADVta
|
|
47
47
|
lazylabel/utils/fast_file_manager.py,sha256=kzbWz_xKufG5bP6sjyZV1fmOKRWPPNeL-xLYZEu_8wE,44697
|
48
48
|
lazylabel/utils/logger.py,sha256=R7z6ifgA-NY-9ZbLlNH0i19zzwXndJ_gkG2J1zpVEhg,1306
|
49
49
|
lazylabel/utils/utils.py,sha256=sYSCoXL27OaLgOZaUkCAhgmKZ7YfhR3Cc5F8nDIa3Ig,414
|
50
|
-
lazylabel_gui-1.3.
|
51
|
-
lazylabel_gui-1.3.
|
52
|
-
lazylabel_gui-1.3.
|
53
|
-
lazylabel_gui-1.3.
|
54
|
-
lazylabel_gui-1.3.
|
55
|
-
lazylabel_gui-1.3.
|
50
|
+
lazylabel_gui-1.3.9.dist-info/licenses/LICENSE,sha256=kSDEIgrWAPd1u2UFGGpC9X71dhzrlzBFs8hbDlENnGE,1092
|
51
|
+
lazylabel_gui-1.3.9.dist-info/METADATA,sha256=MO2RNXV4Y62pLyuVezJdVFS-NcxmVfNJD8lpuPJQ20w,7754
|
52
|
+
lazylabel_gui-1.3.9.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
53
|
+
lazylabel_gui-1.3.9.dist-info/entry_points.txt,sha256=Hd0WwEG9OPTa_ziYjiD0aRh7R6Fupt-wdQ3sspdc1mM,54
|
54
|
+
lazylabel_gui-1.3.9.dist-info/top_level.txt,sha256=YN4uIyrpDBq1wiJaBuZLDipIzyZY0jqJOmmXiPIOUkU,10
|
55
|
+
lazylabel_gui-1.3.9.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|