lazylabel-gui 1.3.8__tar.gz → 1.3.9__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 (61) hide show
  1. {lazylabel_gui-1.3.8/src/lazylabel_gui.egg-info → lazylabel_gui-1.3.9}/PKG-INFO +1 -1
  2. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/pyproject.toml +1 -1
  3. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/config/hotkeys.py +13 -0
  4. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/config/settings.py +4 -0
  5. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/core/file_manager.py +6 -2
  6. lazylabel_gui-1.3.9/src/lazylabel/core/segment_manager.py +420 -0
  7. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/main_window.py +306 -65
  8. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/widgets/settings_widget.py +61 -0
  9. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9/src/lazylabel_gui.egg-info}/PKG-INFO +1 -1
  10. lazylabel_gui-1.3.8/src/lazylabel/core/segment_manager.py +0 -188
  11. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/LICENSE +0 -0
  12. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/README.md +0 -0
  13. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/setup.cfg +0 -0
  14. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/__init__.py +0 -0
  15. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/__main__.py +0 -0
  16. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/config/__init__.py +0 -0
  17. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/config/paths.py +0 -0
  18. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/core/__init__.py +0 -0
  19. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/core/model_manager.py +0 -0
  20. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/main.py +0 -0
  21. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/models/__init__.py +0 -0
  22. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/models/sam2_model.py +0 -0
  23. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/models/sam_model.py +0 -0
  24. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/__init__.py +0 -0
  25. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/control_panel.py +0 -0
  26. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/editable_vertex.py +0 -0
  27. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/hotkey_dialog.py +0 -0
  28. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/hoverable_pixelmap_item.py +0 -0
  29. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/hoverable_polygon_item.py +0 -0
  30. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/modes/__init__.py +0 -0
  31. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/modes/base_mode.py +0 -0
  32. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/modes/multi_view_mode.py +0 -0
  33. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/modes/single_view_mode.py +0 -0
  34. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/numeric_table_widget_item.py +0 -0
  35. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/photo_viewer.py +0 -0
  36. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/reorderable_class_table.py +0 -0
  37. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/right_panel.py +0 -0
  38. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/widgets/__init__.py +0 -0
  39. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/widgets/adjustments_widget.py +0 -0
  40. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/widgets/border_crop_widget.py +0 -0
  41. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/widgets/channel_threshold_widget.py +0 -0
  42. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/widgets/fft_threshold_widget.py +0 -0
  43. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/widgets/fragment_threshold_widget.py +0 -0
  44. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/widgets/model_selection_widget.py +0 -0
  45. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/widgets/status_bar.py +0 -0
  46. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/workers/__init__.py +0 -0
  47. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/workers/image_discovery_worker.py +0 -0
  48. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/workers/multi_view_sam_init_worker.py +0 -0
  49. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/workers/multi_view_sam_update_worker.py +0 -0
  50. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/workers/sam_update_worker.py +0 -0
  51. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/ui/workers/single_view_sam_init_worker.py +0 -0
  52. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/utils/__init__.py +0 -0
  53. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/utils/custom_file_system_model.py +0 -0
  54. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/utils/fast_file_manager.py +0 -0
  55. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/utils/logger.py +0 -0
  56. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel/utils/utils.py +0 -0
  57. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel_gui.egg-info/SOURCES.txt +0 -0
  58. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel_gui.egg-info/dependency_links.txt +0 -0
  59. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel_gui.egg-info/entry_points.txt +0 -0
  60. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/src/lazylabel_gui.egg-info/requires.txt +0 -0
  61. {lazylabel_gui-1.3.8 → lazylabel_gui-1.3.9}/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.3.8
3
+ Version: 1.3.9
4
4
  Summary: An image segmentation GUI for generating ML ready mask tensors and annotations.
5
5
  Author-email: "Deniz N. Cakan" <deniz.n.cakan@gmail.com>
6
6
  License: MIT License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lazylabel-gui"
7
- version = "1.3.8"
7
+ version = "1.3.9"
8
8
  authors = [
9
9
  { name="Deniz N. Cakan", email="deniz.n.cakan@gmail.com" },
10
10
  ]
@@ -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"),
@@ -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)
@@ -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
@@ -0,0 +1,420 @@
1
+ """Segment management functionality."""
2
+
3
+ from typing import Any
4
+
5
+ import cv2
6
+ import numpy as np
7
+ from PyQt6.QtCore import QPointF
8
+
9
+
10
+ class SegmentManager:
11
+ """Manages image segments and classes."""
12
+
13
+ def __init__(self):
14
+ self.segments: list[dict[str, Any]] = []
15
+ self.class_aliases: dict[int, str] = {}
16
+ self.next_class_id: int = 0
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
19
+
20
+ def clear(self) -> None:
21
+ """Clear all segments and reset state."""
22
+ self.segments.clear()
23
+ self.class_aliases.clear()
24
+ self.next_class_id = 0
25
+ self.active_class_id = None
26
+ self.last_toggled_class_id = None
27
+
28
+ def add_segment(self, segment_data: dict[str, Any]) -> None:
29
+ """Add a new segment.
30
+
31
+ If the segment is a polygon, convert QPointF objects to simple lists
32
+ for serialization compatibility.
33
+ """
34
+ if "class_id" not in segment_data:
35
+ # Use active class if available, otherwise use next class ID
36
+ if self.active_class_id is not None:
37
+ segment_data["class_id"] = self.active_class_id
38
+ else:
39
+ segment_data["class_id"] = self.next_class_id
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
+
44
+ # Convert QPointF to list for storage if it's a polygon and contains QPointF objects
45
+ if (
46
+ segment_data.get("type") == "Polygon"
47
+ and segment_data.get("vertices")
48
+ and segment_data["vertices"]
49
+ and isinstance(segment_data["vertices"][0], QPointF)
50
+ ):
51
+ segment_data["vertices"] = [
52
+ [p.x(), p.y()] for p in segment_data["vertices"]
53
+ ]
54
+
55
+ self.segments.append(segment_data)
56
+ self._update_next_class_id()
57
+
58
+ def delete_segments(self, indices: list[int]) -> None:
59
+ """Delete segments by indices."""
60
+ for i in sorted(indices, reverse=True):
61
+ if 0 <= i < len(self.segments):
62
+ del self.segments[i]
63
+ self._update_next_class_id()
64
+
65
+ def assign_segments_to_class(self, indices: list[int]) -> None:
66
+ """Assign selected segments to a class."""
67
+ if not indices:
68
+ return
69
+
70
+ existing_class_ids = [
71
+ self.segments[i]["class_id"]
72
+ for i in indices
73
+ if i < len(self.segments) and self.segments[i].get("class_id") is not None
74
+ ]
75
+
76
+ if existing_class_ids:
77
+ target_class_id = min(existing_class_ids)
78
+ else:
79
+ target_class_id = self.next_class_id
80
+
81
+ for i in indices:
82
+ if i < len(self.segments):
83
+ self.segments[i]["class_id"] = target_class_id
84
+
85
+ self._update_next_class_id()
86
+
87
+ def get_unique_class_ids(self) -> list[int]:
88
+ """Get sorted list of unique class IDs."""
89
+ return sorted(
90
+ {
91
+ seg.get("class_id")
92
+ for seg in self.segments
93
+ if seg.get("class_id") is not None
94
+ }
95
+ )
96
+
97
+ def rasterize_polygon(
98
+ self, vertices: list[QPointF], image_size: tuple[int, int]
99
+ ) -> np.ndarray | None:
100
+ """Convert polygon vertices to binary mask."""
101
+ if not vertices:
102
+ return None
103
+
104
+ h, w = image_size
105
+ points_np = np.array([[p.x(), p.y()] for p in vertices], dtype=np.int32)
106
+ mask = np.zeros((h, w), dtype=np.uint8)
107
+ cv2.fillPoly(mask, [points_np], 1)
108
+ return mask.astype(bool)
109
+
110
+ def create_final_mask_tensor(
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,
116
+ ) -> np.ndarray:
117
+ """Create final mask tensor for saving."""
118
+ h, w = image_size
119
+ id_map = {old_id: new_id for new_id, old_id in enumerate(class_order)}
120
+ num_final_classes = len(class_order)
121
+ final_mask_tensor = np.zeros((h, w, num_final_classes), dtype=np.uint8)
122
+
123
+ for seg in self.segments:
124
+ class_id = seg.get("class_id")
125
+ if class_id not in id_map:
126
+ continue
127
+
128
+ new_channel_idx = id_map[class_id]
129
+
130
+ if seg["type"] == "Polygon":
131
+ # Convert stored list of lists back to QPointF objects for rasterization
132
+ qpoints = [QPointF(p[0], p[1]) for p in seg["vertices"]]
133
+ mask = self.rasterize_polygon(qpoints, image_size)
134
+ else:
135
+ mask = seg.get("mask")
136
+
137
+ if mask is not None:
138
+ final_mask_tensor[:, :, new_channel_idx] = np.logical_or(
139
+ final_mask_tensor[:, :, new_channel_idx], mask
140
+ )
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
+
148
+ return final_mask_tensor
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
+
195
+ def reassign_class_ids(self, new_order: list[int]) -> None:
196
+ """Reassign class IDs based on new order."""
197
+ id_map = {old_id: new_id for new_id, old_id in enumerate(new_order)}
198
+
199
+ for seg in self.segments:
200
+ old_id = seg.get("class_id")
201
+ if old_id in id_map:
202
+ seg["class_id"] = id_map[old_id]
203
+
204
+ # Update aliases
205
+ new_aliases = {
206
+ id_map[old_id]: self.class_aliases.get(old_id, str(old_id))
207
+ for old_id in new_order
208
+ if old_id in self.class_aliases
209
+ }
210
+ self.class_aliases = new_aliases
211
+ self._update_next_class_id()
212
+
213
+ def set_class_alias(self, class_id: int, alias: str) -> None:
214
+ """Set alias for a class."""
215
+ self.class_aliases[class_id] = alias
216
+
217
+ def get_class_alias(self, class_id: int) -> str:
218
+ """Get alias for a class."""
219
+ return self.class_aliases.get(class_id, str(class_id))
220
+
221
+ def set_active_class(self, class_id: int | None) -> None:
222
+ """Set the active class ID."""
223
+ self.active_class_id = class_id
224
+
225
+ def get_active_class(self) -> int | None:
226
+ """Get the active class ID."""
227
+ return self.active_class_id
228
+
229
+ def toggle_active_class(self, class_id: int) -> bool:
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
+
234
+ if self.active_class_id == class_id:
235
+ self.active_class_id = None
236
+ return False
237
+ else:
238
+ self.active_class_id = class_id
239
+ return True
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
+
410
+ def _update_next_class_id(self) -> None:
411
+ """Update the next available class ID."""
412
+ all_ids = {
413
+ seg.get("class_id")
414
+ for seg in self.segments
415
+ if seg.get("class_id") is not None
416
+ }
417
+ if not all_ids:
418
+ self.next_class_id = 0
419
+ else:
420
+ self.next_class_id = max(all_ids) + 1