lazylabel-gui 1.3.7__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.
- {lazylabel_gui-1.3.7/src/lazylabel_gui.egg-info → lazylabel_gui-1.3.9}/PKG-INFO +1 -1
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/pyproject.toml +1 -1
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/config/hotkeys.py +13 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/config/settings.py +4 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/core/file_manager.py +6 -2
- lazylabel_gui-1.3.9/src/lazylabel/core/segment_manager.py +420 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/main_window.py +361 -95
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/widgets/settings_widget.py +61 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9/src/lazylabel_gui.egg-info}/PKG-INFO +1 -1
- lazylabel_gui-1.3.7/src/lazylabel/core/segment_manager.py +0 -188
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/LICENSE +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/README.md +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/setup.cfg +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/__init__.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/__main__.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/config/__init__.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/config/paths.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/core/__init__.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/core/model_manager.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/main.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/models/__init__.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/models/sam2_model.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/models/sam_model.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/__init__.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/control_panel.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/editable_vertex.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/hotkey_dialog.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/hoverable_pixelmap_item.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/hoverable_polygon_item.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/modes/__init__.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/modes/base_mode.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/modes/multi_view_mode.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/modes/single_view_mode.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/numeric_table_widget_item.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/photo_viewer.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/reorderable_class_table.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/right_panel.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/widgets/__init__.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/widgets/adjustments_widget.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/widgets/border_crop_widget.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/widgets/channel_threshold_widget.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/widgets/fft_threshold_widget.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/widgets/fragment_threshold_widget.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/widgets/model_selection_widget.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/widgets/status_bar.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/workers/__init__.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/workers/image_discovery_worker.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/workers/multi_view_sam_init_worker.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/workers/multi_view_sam_update_worker.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/workers/sam_update_worker.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/ui/workers/single_view_sam_init_worker.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/utils/__init__.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/utils/custom_file_system_model.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/utils/fast_file_manager.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/utils/logger.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel/utils/utils.py +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel_gui.egg-info/SOURCES.txt +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel_gui.egg-info/dependency_links.txt +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel_gui.egg-info/entry_points.txt +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel_gui.egg-info/requires.txt +0 -0
- {lazylabel_gui-1.3.7 → lazylabel_gui-1.3.9}/src/lazylabel_gui.egg-info/top_level.txt +0 -0
@@ -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
|