PyImageLabeling 1.0.0__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.
- PyImageLabeling/__init__.py +22 -0
- PyImageLabeling/config.json +289 -0
- PyImageLabeling/controller/Controller.py +25 -0
- PyImageLabeling/controller/Events.py +147 -0
- PyImageLabeling/controller/FileEvents.py +69 -0
- PyImageLabeling/controller/ImageEvents.py +32 -0
- PyImageLabeling/controller/LabelEvents.py +219 -0
- PyImageLabeling/controller/LabelingEvents.py +123 -0
- PyImageLabeling/controller/settings/ContourFillinSetting.py +93 -0
- PyImageLabeling/controller/settings/CoutourFillingApplyCancel.py +37 -0
- PyImageLabeling/controller/settings/EraserSetting.py +73 -0
- PyImageLabeling/controller/settings/LabelSetting.py +91 -0
- PyImageLabeling/controller/settings/MagicPenSetting.py +125 -0
- PyImageLabeling/controller/settings/OpacitySetting.py +66 -0
- PyImageLabeling/controller/settings/PaintBrushSetting.py +66 -0
- PyImageLabeling/icons/apply.png +0 -0
- PyImageLabeling/icons/asterisk-green.png +0 -0
- PyImageLabeling/icons/asterisk-red.png +0 -0
- PyImageLabeling/icons/back.png +0 -0
- PyImageLabeling/icons/border.png +0 -0
- PyImageLabeling/icons/cancel.png +0 -0
- PyImageLabeling/icons/cleaner.png +0 -0
- PyImageLabeling/icons/close.png +0 -0
- PyImageLabeling/icons/down.png +0 -0
- PyImageLabeling/icons/ellipse.png +0 -0
- PyImageLabeling/icons/eraser.png +0 -0
- PyImageLabeling/icons/filling.png +0 -0
- PyImageLabeling/icons/logoMAIA.png +0 -0
- PyImageLabeling/icons/magic.png +0 -0
- PyImageLabeling/icons/maia.png +0 -0
- PyImageLabeling/icons/maia1.png +0 -0
- PyImageLabeling/icons/maia3.ico +0 -0
- PyImageLabeling/icons/maia_icon.png +0 -0
- PyImageLabeling/icons/move.png +0 -0
- PyImageLabeling/icons/opacity.png +0 -0
- PyImageLabeling/icons/open_image.png +0 -0
- PyImageLabeling/icons/open_layer.png +0 -0
- PyImageLabeling/icons/paint.png +0 -0
- PyImageLabeling/icons/plus.png +0 -0
- PyImageLabeling/icons/polygon.png +0 -0
- PyImageLabeling/icons/rectangle.png +0 -0
- PyImageLabeling/icons/reset.png +0 -0
- PyImageLabeling/icons/save.png +0 -0
- PyImageLabeling/icons/setting.png +0 -0
- PyImageLabeling/icons/transparency.png:Zone.Identifier +4 -0
- PyImageLabeling/icons/up.png +0 -0
- PyImageLabeling/icons/visibility.png +0 -0
- PyImageLabeling/icons/zoom_minus.png +0 -0
- PyImageLabeling/icons/zoom_plus.png +0 -0
- PyImageLabeling/model/Core.py +795 -0
- PyImageLabeling/model/File/Files.py +166 -0
- PyImageLabeling/model/File/NextImage.py +36 -0
- PyImageLabeling/model/File/PreviousImage.py +19 -0
- PyImageLabeling/model/Image/MoveImage.py +32 -0
- PyImageLabeling/model/Image/ResetMoveZoomImage.py +16 -0
- PyImageLabeling/model/Image/ZoomMinus.py +25 -0
- PyImageLabeling/model/Image/ZoomPlus.py +16 -0
- PyImageLabeling/model/Labeling/ClearAll.py +22 -0
- PyImageLabeling/model/Labeling/ContourFilling.py +135 -0
- PyImageLabeling/model/Labeling/Ellipse.py +350 -0
- PyImageLabeling/model/Labeling/Eraser.py +131 -0
- PyImageLabeling/model/Labeling/MagicPen.py +131 -0
- PyImageLabeling/model/Labeling/PaintBrush.py +207 -0
- PyImageLabeling/model/Labeling/Polygon.py +279 -0
- PyImageLabeling/model/Labeling/Rectangle.py +248 -0
- PyImageLabeling/model/Labeling/Undo.py +12 -0
- PyImageLabeling/model/Model.py +40 -0
- PyImageLabeling/model/Utils.py +40 -0
- PyImageLabeling/old_version/label_rectangle_properties.json +6 -0
- PyImageLabeling/old_version/main.py +2073 -0
- PyImageLabeling/old_version/models/EraseSettingsDialog.py +51 -0
- PyImageLabeling/old_version/models/LabeledRectangle.py +80 -0
- PyImageLabeling/old_version/models/MagicSettingsDialog.py +119 -0
- PyImageLabeling/old_version/models/OverlayOpacityDialog.py +63 -0
- PyImageLabeling/old_version/models/PaintSettingsDialog.py +289 -0
- PyImageLabeling/old_version/models/PointItem.py +66 -0
- PyImageLabeling/old_version/models/ProcessWorker.py +52 -0
- PyImageLabeling/old_version/models/ZoomableGraphicsView.py +1214 -0
- PyImageLabeling/old_version/models/tools/ContourTool.py +279 -0
- PyImageLabeling/old_version/models/tools/EraserTool.py +290 -0
- PyImageLabeling/old_version/models/tools/MagicPenTool.py +199 -0
- PyImageLabeling/old_version/models/tools/OverlayTool.py +179 -0
- PyImageLabeling/old_version/models/tools/PaintTool.py +68 -0
- PyImageLabeling/old_version/models/tools/PolygonTool.py +786 -0
- PyImageLabeling/old_version/models/tools/RectangleTool.py +1036 -0
- PyImageLabeling/parameters.json +1 -0
- PyImageLabeling/style.css +611 -0
- PyImageLabeling/view/Builder.py +333 -0
- PyImageLabeling/view/QBackgroundItem.py +30 -0
- PyImageLabeling/view/QWidgets.py +10 -0
- PyImageLabeling/view/View.py +226 -0
- PyImageLabeling/view/ZoomableGraphicsView.py +91 -0
- PyImageLabeling/view/__init__.py +0 -0
- pyimagelabeling-1.0.0.dist-info/METADATA +55 -0
- pyimagelabeling-1.0.0.dist-info/RECORD +99 -0
- pyimagelabeling-1.0.0.dist-info/WHEEL +5 -0
- pyimagelabeling-1.0.0.dist-info/licenses/LICENCE +22 -0
- pyimagelabeling-1.0.0.dist-info/top_level.txt +2 -0
- pypi/publish_pypi.py +18 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
from PyQt6.QtWidgets import QMessageBox, QProgressDialog
|
|
2
|
+
from PyQt6.QtCore import Qt, QPointF, QPoint
|
|
3
|
+
from PyQt6.QtGui import QPixmap, QImage
|
|
4
|
+
from models.ProcessWorker import ProcessWorker
|
|
5
|
+
from models.PointItem import PointItem
|
|
6
|
+
import numpy as np
|
|
7
|
+
import cv2
|
|
8
|
+
import traceback
|
|
9
|
+
|
|
10
|
+
class ContourTool:
|
|
11
|
+
def apply_contour(self):
|
|
12
|
+
"""Detects contours from the base image and applies the contour layer with improved parameters."""
|
|
13
|
+
if not hasattr(self, 'base_pixmap') or self.base_pixmap is None:
|
|
14
|
+
msg_box = QMessageBox(self)
|
|
15
|
+
msg_box.setWindowTitle("Error")
|
|
16
|
+
msg_box.setText("No image loaded.")
|
|
17
|
+
msg_box.setStyleSheet("""
|
|
18
|
+
QMessageBox {
|
|
19
|
+
background-color: #000000; /* Pure black background */
|
|
20
|
+
color: white; /* White text */
|
|
21
|
+
font-size: 14px;
|
|
22
|
+
border: 1px solid #444444;
|
|
23
|
+
}
|
|
24
|
+
QLabel {
|
|
25
|
+
color: white; /* Ensures the message text is white */
|
|
26
|
+
background-color: #000000;
|
|
27
|
+
}
|
|
28
|
+
QPushButton {
|
|
29
|
+
background-color: #000000; /* Black buttons */
|
|
30
|
+
color: white;
|
|
31
|
+
border: 1px solid #555555;
|
|
32
|
+
border-radius: 5px;
|
|
33
|
+
padding: 5px 10px;
|
|
34
|
+
}
|
|
35
|
+
QPushButton:hover {
|
|
36
|
+
background-color: #222222; /* Slightly lighter on hover */
|
|
37
|
+
}
|
|
38
|
+
""")
|
|
39
|
+
msg_box.exec()
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
# Get the pixmap data
|
|
43
|
+
image = self.base_pixmap.toImage()
|
|
44
|
+
width, height = image.width(), image.height()
|
|
45
|
+
|
|
46
|
+
# Convert QImage to NumPy array
|
|
47
|
+
buffer = image.constBits()
|
|
48
|
+
buffer.setsize(height * width * 4) # 4 channels (RGBA)
|
|
49
|
+
img_array = np.frombuffer(buffer, dtype=np.uint8).reshape((height, width, 4))
|
|
50
|
+
|
|
51
|
+
# Convert to grayscale (use OpenCV)
|
|
52
|
+
gray = cv2.cvtColor(img_array, cv2.COLOR_RGBA2GRAY)
|
|
53
|
+
|
|
54
|
+
# Apply Gaussian blur to reduce noise
|
|
55
|
+
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
|
|
56
|
+
|
|
57
|
+
# Apply Canny edge detection with adjusted parameters
|
|
58
|
+
edges = cv2.Canny(blurred, 50, 150) # Lower thresholds for more sensitive detection
|
|
59
|
+
|
|
60
|
+
# Apply slight dilation to connect nearby edges
|
|
61
|
+
kernel = np.ones((2, 2), np.uint8)
|
|
62
|
+
edges = cv2.dilate(edges, kernel, iterations=1)
|
|
63
|
+
|
|
64
|
+
# Find contours with hierarchy to better handle nested shapes
|
|
65
|
+
contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_TC89_L1)
|
|
66
|
+
|
|
67
|
+
# Filter out very small contours that might be noise
|
|
68
|
+
min_contour_area = 10 # Adjust based on your needs
|
|
69
|
+
contours = [cnt for cnt in contours if cv2.contourArea(cnt) > min_contour_area]
|
|
70
|
+
|
|
71
|
+
# Save contours in image_label for later use
|
|
72
|
+
self.contours = contours
|
|
73
|
+
|
|
74
|
+
# Create a transparent layer to visualize the contours (Blue lines)
|
|
75
|
+
contour_layer = np.zeros((height, width, 4), dtype=np.uint8)
|
|
76
|
+
cv2.drawContours(contour_layer, contours, -1, (0, 0, 255, 255), 1)
|
|
77
|
+
|
|
78
|
+
# Convert NumPy array to QImage
|
|
79
|
+
contour_qimage = QImage(contour_layer.data, width, height, width * 4, QImage.Format.Format_RGBA8888)
|
|
80
|
+
|
|
81
|
+
# Set the overlay in the image viewer
|
|
82
|
+
overlay_pixmap = QPixmap.fromImage(contour_qimage)
|
|
83
|
+
self.add_overlay(overlay_pixmap)
|
|
84
|
+
|
|
85
|
+
# Mark that the contour layer is applied
|
|
86
|
+
self.contour_layer_applied = True
|
|
87
|
+
|
|
88
|
+
msg_box = QMessageBox(self)
|
|
89
|
+
msg_box.setWindowTitle("Contour")
|
|
90
|
+
msg_box.setText("Contour layer applied successfully.")
|
|
91
|
+
msg_box.setStyleSheet("""
|
|
92
|
+
QMessageBox {
|
|
93
|
+
background-color: #000000; /* Pure black background */
|
|
94
|
+
color: white; /* White text */
|
|
95
|
+
font-size: 14px;
|
|
96
|
+
border: 1px solid #444444;
|
|
97
|
+
}
|
|
98
|
+
QLabel {
|
|
99
|
+
color: white; /* Ensures the message text is white */
|
|
100
|
+
background-color: #000000;
|
|
101
|
+
}
|
|
102
|
+
QPushButton {
|
|
103
|
+
background-color: #000000; /* Black buttons */
|
|
104
|
+
color: white;
|
|
105
|
+
border: 1px solid #555555;
|
|
106
|
+
border-radius: 5px;
|
|
107
|
+
padding: 5px 10px;
|
|
108
|
+
}
|
|
109
|
+
QPushButton:hover {
|
|
110
|
+
background-color: #222222; /* Slightly lighter on hover */
|
|
111
|
+
}
|
|
112
|
+
""")
|
|
113
|
+
msg_box.exec()
|
|
114
|
+
|
|
115
|
+
def fill_contour(self):
|
|
116
|
+
"""Fill the contour clicked by the user."""
|
|
117
|
+
if not hasattr(self, "contour_layer_applied") or not self.contour_layer_applied:
|
|
118
|
+
QMessageBox.warning(self, "Error", "Contour layer is not applied.")
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
if not hasattr(self, 'base_pixmap') or self.base_pixmap is None:
|
|
122
|
+
QMessageBox.warning(self, "Error", "No image loaded.")
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
if not hasattr(self, "last_mouse_pos") or not self.last_mouse_pos:
|
|
126
|
+
QMessageBox.warning(self, "Error", "No position to fill. Click on an area first.")
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
# Create progress dialog
|
|
130
|
+
progress = QProgressDialog("Processing...", "Cancel", 0, 0, self)
|
|
131
|
+
progress.setWindowModality(Qt.WindowModality.WindowModal)
|
|
132
|
+
progress.show()
|
|
133
|
+
|
|
134
|
+
# Create worker thread for fill operation
|
|
135
|
+
self.worker = ProcessWorker(self._fill_contour_worker, args=[self.last_mouse_pos], timeout=self.process_timeout)
|
|
136
|
+
self.worker.finished.connect(lambda points: self._handle_fill_contour_complete(points, progress))
|
|
137
|
+
self.worker.error.connect(lambda error: self._handle_fill_contour_error(error, progress))
|
|
138
|
+
self.worker.start()
|
|
139
|
+
|
|
140
|
+
def _fill_contour_worker(self, pos):
|
|
141
|
+
"""Worker function to perform the contour fill with improved gap tolerance."""
|
|
142
|
+
try:
|
|
143
|
+
if isinstance(pos, QPointF):
|
|
144
|
+
scene_pos = QPoint(int(pos.x()), int(pos.y()))
|
|
145
|
+
else:
|
|
146
|
+
scene_pos = pos
|
|
147
|
+
|
|
148
|
+
scene_pos = self.mapToScene(scene_pos)
|
|
149
|
+
|
|
150
|
+
item_pos = QPointF(0, 0)
|
|
151
|
+
if hasattr(self, 'pixmap_item') and self.pixmap_item:
|
|
152
|
+
item_pos = self.pixmap_item.pos()
|
|
153
|
+
|
|
154
|
+
image_x = int(scene_pos.x() - item_pos.x())
|
|
155
|
+
image_y = int(scene_pos.y() - item_pos.y())
|
|
156
|
+
|
|
157
|
+
if not hasattr(self, 'base_pixmap') or self.base_pixmap is None:
|
|
158
|
+
raise ValueError("No base pixmap available")
|
|
159
|
+
|
|
160
|
+
width = self.base_pixmap.width()
|
|
161
|
+
height = self.base_pixmap.height()
|
|
162
|
+
|
|
163
|
+
if not (0 <= image_x < width and 0 <= image_y < height):
|
|
164
|
+
raise ValueError("Click position outside image bounds")
|
|
165
|
+
|
|
166
|
+
# Ensure contours are available
|
|
167
|
+
contours = self.contours
|
|
168
|
+
if not contours:
|
|
169
|
+
raise ValueError("No contours found")
|
|
170
|
+
|
|
171
|
+
# Find the specific contour that contains the click position
|
|
172
|
+
target_contour = None
|
|
173
|
+
for contour in contours:
|
|
174
|
+
if cv2.pointPolygonTest(contour, (image_x, image_y), False) >= 0:
|
|
175
|
+
target_contour = contour
|
|
176
|
+
break
|
|
177
|
+
|
|
178
|
+
if target_contour is None:
|
|
179
|
+
# If no direct contour contains the point, try nearby points within a tolerance
|
|
180
|
+
tolerance = 5 # Adjust this value based on desired gap tolerance
|
|
181
|
+
for dx in range(-tolerance, tolerance + 1):
|
|
182
|
+
for dy in range(-tolerance, tolerance + 1):
|
|
183
|
+
check_x = image_x + dx
|
|
184
|
+
check_y = image_y + dy
|
|
185
|
+
|
|
186
|
+
# Skip if out of bounds
|
|
187
|
+
if not (0 <= check_x < width and 0 <= check_y < height):
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
for contour in contours:
|
|
191
|
+
if cv2.pointPolygonTest(contour, (check_x, check_y), False) >= 0:
|
|
192
|
+
target_contour = contour
|
|
193
|
+
break
|
|
194
|
+
|
|
195
|
+
if target_contour is not None:
|
|
196
|
+
break
|
|
197
|
+
|
|
198
|
+
if target_contour is not None:
|
|
199
|
+
break
|
|
200
|
+
|
|
201
|
+
if target_contour is None:
|
|
202
|
+
raise ValueError("Click position is outside any detected contour (even with tolerance)")
|
|
203
|
+
|
|
204
|
+
# Create a mask from the specific contour
|
|
205
|
+
mask = np.zeros((height, width), dtype=np.uint8)
|
|
206
|
+
cv2.drawContours(mask, [target_contour], 0, 255, -1) # Fill the contour with white
|
|
207
|
+
|
|
208
|
+
# Apply morphological closing to fill small gaps in the contour
|
|
209
|
+
kernel_size = 3 # Adjust based on the typical gap size
|
|
210
|
+
kernel = np.ones((kernel_size, kernel_size), np.uint8)
|
|
211
|
+
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
|
212
|
+
|
|
213
|
+
# Extract points inside the filled contour
|
|
214
|
+
filled_points = []
|
|
215
|
+
for y in range(height):
|
|
216
|
+
for x in range(width):
|
|
217
|
+
if mask[y, x] == 255:
|
|
218
|
+
filled_points.append(QPointF(float(x), float(y)))
|
|
219
|
+
|
|
220
|
+
return filled_points
|
|
221
|
+
|
|
222
|
+
except Exception as e:
|
|
223
|
+
print(f"Fill contour error: {type(e).__name__} - {str(e)}")
|
|
224
|
+
raise
|
|
225
|
+
|
|
226
|
+
def _handle_fill_contour_complete(self, new_points, progress):
|
|
227
|
+
"""Handles the completion of the fill operation."""
|
|
228
|
+
try:
|
|
229
|
+
progress.close()
|
|
230
|
+
|
|
231
|
+
if not new_points:
|
|
232
|
+
QMessageBox.information(self, "Fill Complete", "No points were filled.")
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
# Ensure the ZoomableGraphicsView has the necessary attributes
|
|
236
|
+
if not hasattr(self, 'scene'):
|
|
237
|
+
QMessageBox.warning(self, "Error", "Graphics view is not properly initialized.")
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
# Process points in chunks to avoid UI freezing
|
|
241
|
+
chunk_size = 10000
|
|
242
|
+
chunks = [new_points[i:i + chunk_size] for i in range(0, len(new_points), chunk_size)]
|
|
243
|
+
|
|
244
|
+
# Ensure points and points_history attributes exist
|
|
245
|
+
if not hasattr(self, 'points'):
|
|
246
|
+
self.image_label.points = []
|
|
247
|
+
if not hasattr(self, 'points_history'):
|
|
248
|
+
self.image_label.points_history = []
|
|
249
|
+
|
|
250
|
+
# Create point items and add to the image label
|
|
251
|
+
new_point_items = []
|
|
252
|
+
for chunk in chunks:
|
|
253
|
+
chunk_items = []
|
|
254
|
+
for point in chunk:
|
|
255
|
+
point_item = PointItem(self.point_label, point.x(), point.y(), self.point_radius, self.point_color)
|
|
256
|
+
chunk_items.append(point_item)
|
|
257
|
+
|
|
258
|
+
new_point_items.extend(chunk_items)
|
|
259
|
+
|
|
260
|
+
# Add the new points to the existing points list
|
|
261
|
+
self.points.extend(new_point_items)
|
|
262
|
+
|
|
263
|
+
# Add to points history for potential undo functionality
|
|
264
|
+
self.points_history.append(new_point_items)
|
|
265
|
+
|
|
266
|
+
# Update the points overlay to reflect new points
|
|
267
|
+
if hasattr(self, 'update_points_overlay'):
|
|
268
|
+
self.update_points_overlay()
|
|
269
|
+
|
|
270
|
+
QMessageBox.information(self, "Fill Complete", f"Filled {len(new_points)} points.")
|
|
271
|
+
|
|
272
|
+
except Exception as e:
|
|
273
|
+
QMessageBox.warning(self, "Rendering Error", f"Failed to render fill: {str(e)}")
|
|
274
|
+
print(f"Fill rendering error: {traceback.format_exc()}")
|
|
275
|
+
|
|
276
|
+
def _handle_fill_contour_error(self, error, progress):
|
|
277
|
+
"""Handles any errors that occur during the fill operation."""
|
|
278
|
+
progress.close()
|
|
279
|
+
QMessageBox.warning(self, "Error", f"Fill operation failed: {error}")
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
from PyQt6.QtCore import QRectF, QPointF, QTimer, Qt
|
|
2
|
+
from PyQt6.QtGui import QPainter
|
|
3
|
+
|
|
4
|
+
class EraserTool:
|
|
5
|
+
def erase_point(self, scene_pos):
|
|
6
|
+
"""
|
|
7
|
+
Highly optimized erase function that minimizes redraws for improved performance
|
|
8
|
+
with enhanced smoothness.
|
|
9
|
+
"""
|
|
10
|
+
# Check if there are any points to erase or if we're in absolute erase mode
|
|
11
|
+
if not self.points and not (self.absolute_erase_mode and self.overlay_pixmap_item):
|
|
12
|
+
return
|
|
13
|
+
|
|
14
|
+
# Calculate the eraser's squared radius (faster comparison)
|
|
15
|
+
eraser_size_squared = self.eraser_size ** 2
|
|
16
|
+
|
|
17
|
+
# Track if we've made changes that require updates
|
|
18
|
+
needs_update = False
|
|
19
|
+
|
|
20
|
+
# OPTIMIZATION: Batch erase operations without updating the display for each point
|
|
21
|
+
# Store the current eraser position for batch processing
|
|
22
|
+
if not hasattr(self, 'erase_positions'):
|
|
23
|
+
self.erase_positions = []
|
|
24
|
+
|
|
25
|
+
# Add this position to the batch
|
|
26
|
+
self.erase_positions.append(scene_pos)
|
|
27
|
+
|
|
28
|
+
# Create the current eraser rectangle for update tracking
|
|
29
|
+
eraser_rect = QRectF(
|
|
30
|
+
scene_pos.x() - self.eraser_size,
|
|
31
|
+
scene_pos.y() - self.eraser_size,
|
|
32
|
+
self.eraser_size * 2,
|
|
33
|
+
self.eraser_size * 2
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Initialize or update the cumulative erase area
|
|
37
|
+
if not hasattr(self, 'current_erase_update_rect') or self.current_erase_update_rect is None:
|
|
38
|
+
self.current_erase_update_rect = QRectF(eraser_rect)
|
|
39
|
+
else:
|
|
40
|
+
self.current_erase_update_rect = self.current_erase_update_rect.united(eraser_rect)
|
|
41
|
+
|
|
42
|
+
# Handle absolute erase mode immediately for better visual feedback
|
|
43
|
+
if self.absolute_erase_mode and self.overlay_pixmap_item:
|
|
44
|
+
overlay_pixmap = self.overlay_pixmap_item.pixmap()
|
|
45
|
+
painter = QPainter(overlay_pixmap)
|
|
46
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
|
47
|
+
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Clear)
|
|
48
|
+
painter.setPen(Qt.PenStyle.NoPen)
|
|
49
|
+
painter.setBrush(Qt.GlobalColor.black)
|
|
50
|
+
|
|
51
|
+
# Draw with softer edges for smoother erasing
|
|
52
|
+
painter.drawEllipse(scene_pos, self.eraser_size, self.eraser_size)
|
|
53
|
+
painter.end()
|
|
54
|
+
|
|
55
|
+
self.overlay_pixmap_item.setPixmap(overlay_pixmap)
|
|
56
|
+
self.scene.update(eraser_rect)
|
|
57
|
+
|
|
58
|
+
# Process points immediately for normal mode as well
|
|
59
|
+
if not self.absolute_erase_mode:
|
|
60
|
+
if self.overlay_pixmap_item is not None:
|
|
61
|
+
overlay_pixmap = self.overlay_pixmap_item.pixmap()
|
|
62
|
+
painter = QPainter(overlay_pixmap)
|
|
63
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
|
64
|
+
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Clear)
|
|
65
|
+
painter.setPen(Qt.PenStyle.NoPen)
|
|
66
|
+
painter.setBrush(Qt.GlobalColor.black)
|
|
67
|
+
|
|
68
|
+
# Draw with softer edges for smoother erasing
|
|
69
|
+
painter.drawEllipse(scene_pos, self.eraser_size, self.eraser_size)
|
|
70
|
+
painter.end()
|
|
71
|
+
|
|
72
|
+
self.overlay_pixmap_item.setPixmap(overlay_pixmap)
|
|
73
|
+
self.scene.update(eraser_rect)
|
|
74
|
+
|
|
75
|
+
# IMPROVEMENT: Process points in smaller batches for more responsive updates
|
|
76
|
+
# Only process points when we have enough positions or timer triggers
|
|
77
|
+
batch_size = 3 # Reduced from 5 for more frequent updates
|
|
78
|
+
if len(self.erase_positions) < batch_size:
|
|
79
|
+
# Start the erase timer if it's not active
|
|
80
|
+
if not self.erase_timer.isActive():
|
|
81
|
+
self.erase_timer.start(50) # Reduced timer for smoother updates
|
|
82
|
+
|
|
83
|
+
# Provide immediate visual feedback by updating the area
|
|
84
|
+
self.scene.update(eraser_rect)
|
|
85
|
+
return # Skip point processing until we have a batch
|
|
86
|
+
|
|
87
|
+
# IMPROVEMENT: Use interpolation for smoother erasing between points
|
|
88
|
+
interpolated_positions = []
|
|
89
|
+
if len(self.erase_positions) >= 2:
|
|
90
|
+
for i in range(len(self.erase_positions) - 1):
|
|
91
|
+
start_pos = self.erase_positions[i]
|
|
92
|
+
end_pos = self.erase_positions[i + 1]
|
|
93
|
+
|
|
94
|
+
# Calculate distance between points
|
|
95
|
+
dx = end_pos.x() - start_pos.x()
|
|
96
|
+
dy = end_pos.y() - start_pos.y()
|
|
97
|
+
distance = (dx**2 + dy**2)**0.5
|
|
98
|
+
|
|
99
|
+
# Only interpolate if points are far enough apart
|
|
100
|
+
if distance > self.eraser_size / 2:
|
|
101
|
+
# Number of interpolation steps based on distance
|
|
102
|
+
steps = max(2, min(10, int(distance / (self.eraser_size / 3))))
|
|
103
|
+
|
|
104
|
+
for step in range(1, steps):
|
|
105
|
+
t = step / steps
|
|
106
|
+
interp_x = start_pos.x() + dx * t
|
|
107
|
+
interp_y = start_pos.y() + dy * t
|
|
108
|
+
interpolated_positions.append(QPointF(interp_x, interp_y))
|
|
109
|
+
|
|
110
|
+
# Combine original and interpolated positions
|
|
111
|
+
positions_to_check = self.erase_positions.copy() + interpolated_positions
|
|
112
|
+
|
|
113
|
+
# Create a single large update rect covering all erase positions
|
|
114
|
+
full_update_rect = None
|
|
115
|
+
for pos in positions_to_check:
|
|
116
|
+
current_rect = QRectF(
|
|
117
|
+
pos.x() - self.eraser_size,
|
|
118
|
+
pos.y() - self.eraser_size,
|
|
119
|
+
self.eraser_size * 2,
|
|
120
|
+
self.eraser_size * 2
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if full_update_rect is None:
|
|
124
|
+
full_update_rect = QRectF(current_rect)
|
|
125
|
+
else:
|
|
126
|
+
full_update_rect = full_update_rect.united(current_rect)
|
|
127
|
+
|
|
128
|
+
# Reset the batch
|
|
129
|
+
self.erase_positions = []
|
|
130
|
+
|
|
131
|
+
# Process the accumulated erase positions
|
|
132
|
+
all_erased_points = []
|
|
133
|
+
|
|
134
|
+
# OPTIMIZATION: Create a fast lookup for points to be removed
|
|
135
|
+
points_to_remove = set()
|
|
136
|
+
|
|
137
|
+
# OPTIMIZATION: Use spatial partitioning approach for large point sets
|
|
138
|
+
if len(self.points) > 1000:
|
|
139
|
+
# Only check points that could be within the eraser area
|
|
140
|
+
for index, point_item in enumerate(self.points):
|
|
141
|
+
try:
|
|
142
|
+
# Get point position using the most likely method
|
|
143
|
+
if hasattr(point_item, 'get_position'):
|
|
144
|
+
point_center = point_item.get_position()
|
|
145
|
+
elif hasattr(point_item, '_pos'):
|
|
146
|
+
point_center = point_item._pos
|
|
147
|
+
elif hasattr(point_item, 'x') and hasattr(point_item, 'y'):
|
|
148
|
+
point_center = QPointF(point_item.x(), point_item.y())
|
|
149
|
+
elif hasattr(point_item, 'scenePos'):
|
|
150
|
+
point_center = point_item.scenePos()
|
|
151
|
+
else:
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
# Quick bounding box test first (much faster than distance calculation)
|
|
155
|
+
if (point_center.x() >= full_update_rect.left() and
|
|
156
|
+
point_center.x() <= full_update_rect.right() and
|
|
157
|
+
point_center.y() >= full_update_rect.top() and
|
|
158
|
+
point_center.y() <= full_update_rect.bottom()):
|
|
159
|
+
|
|
160
|
+
# Now check actual distance to any erase position
|
|
161
|
+
for pos in positions_to_check:
|
|
162
|
+
distance_squared = (point_center.x() - pos.x())**2 + (point_center.y() - pos.y())**2
|
|
163
|
+
if distance_squared <= eraser_size_squared:
|
|
164
|
+
points_to_remove.add(index)
|
|
165
|
+
all_erased_points.append(point_item)
|
|
166
|
+
break
|
|
167
|
+
except Exception:
|
|
168
|
+
pass
|
|
169
|
+
else:
|
|
170
|
+
# For smaller point sets, just check each point against all positions
|
|
171
|
+
for index, point_item in enumerate(self.points):
|
|
172
|
+
try:
|
|
173
|
+
if hasattr(point_item, 'get_position'):
|
|
174
|
+
point_center = point_item.get_position()
|
|
175
|
+
elif hasattr(point_item, '_pos'):
|
|
176
|
+
point_center = point_item._pos
|
|
177
|
+
elif hasattr(point_item, 'x') and hasattr(point_item, 'y'):
|
|
178
|
+
point_center = QPointF(point_item.x(), point_item.y())
|
|
179
|
+
elif hasattr(point_item, 'scenePos'):
|
|
180
|
+
point_center = point_item.scenePos()
|
|
181
|
+
else:
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
# Check against all erase positions
|
|
185
|
+
for pos in positions_to_check:
|
|
186
|
+
distance_squared = (point_center.x() - pos.x())**2 + (point_center.y() - pos.y())**2
|
|
187
|
+
if distance_squared <= eraser_size_squared:
|
|
188
|
+
points_to_remove.add(index)
|
|
189
|
+
all_erased_points.append(point_item)
|
|
190
|
+
break
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
# OPTIMIZATION: Remove points in reverse order to avoid index shifting
|
|
195
|
+
if points_to_remove:
|
|
196
|
+
# Sort indices in descending order
|
|
197
|
+
indices_list = sorted(points_to_remove, reverse=True)
|
|
198
|
+
|
|
199
|
+
# Remove points from the scene
|
|
200
|
+
for point_item in all_erased_points:
|
|
201
|
+
try:
|
|
202
|
+
self.scene.removeItem(point_item)
|
|
203
|
+
except Exception:
|
|
204
|
+
pass
|
|
205
|
+
|
|
206
|
+
# Remove from the points list (in reverse order)
|
|
207
|
+
for index in indices_list:
|
|
208
|
+
if 0 <= index < len(self.points):
|
|
209
|
+
del self.points[index]
|
|
210
|
+
|
|
211
|
+
# Store erased points for undo functionality
|
|
212
|
+
if all_erased_points:
|
|
213
|
+
self.erased_points_history.append(all_erased_points)
|
|
214
|
+
self.current_stroke.extend([('erase', point) for point in all_erased_points])
|
|
215
|
+
needs_update = True
|
|
216
|
+
|
|
217
|
+
# OPTIMIZATION: Force a full redraw of the points overlay after batch erasing
|
|
218
|
+
# This is more efficient than incremental updates for large erasing operations
|
|
219
|
+
if needs_update:
|
|
220
|
+
# Signal that we need a complete redraw
|
|
221
|
+
if hasattr(self, 'last_rendered_points_count'):
|
|
222
|
+
self.last_rendered_points_count = 0
|
|
223
|
+
|
|
224
|
+
# Set the current update rect to the full area affected
|
|
225
|
+
if full_update_rect is not None:
|
|
226
|
+
self.current_erase_update_rect = full_update_rect
|
|
227
|
+
|
|
228
|
+
# Flag that we need to update the points overlay
|
|
229
|
+
self.points_overlay_needs_update = True
|
|
230
|
+
|
|
231
|
+
# IMPROVEMENT: Progressive rendering for smoother updates
|
|
232
|
+
# Set up timer for delayed overlay update with progressive timing
|
|
233
|
+
if not hasattr(self, 'batch_update_timer'):
|
|
234
|
+
self.batch_update_timer = QTimer()
|
|
235
|
+
self.batch_update_timer.timeout.connect(self.process_erase_batch_update)
|
|
236
|
+
self.batch_update_timer.setSingleShot(True)
|
|
237
|
+
|
|
238
|
+
# Immediate visual feedback with a shorter delay for better responsiveness
|
|
239
|
+
if full_update_rect is not None:
|
|
240
|
+
self.scene.update(full_update_rect)
|
|
241
|
+
|
|
242
|
+
# IMPROVEMENT: Adjust delay based on the number of points for smoother experience
|
|
243
|
+
point_count = len(self.points)
|
|
244
|
+
if point_count > 20000:
|
|
245
|
+
delay = 16
|
|
246
|
+
elif point_count > 10000:
|
|
247
|
+
delay = 10
|
|
248
|
+
elif point_count > 5000:
|
|
249
|
+
delay = 8
|
|
250
|
+
else:
|
|
251
|
+
delay = 4
|
|
252
|
+
|
|
253
|
+
# Reset the timer for batched updates
|
|
254
|
+
self.batch_update_timer.start(delay)
|
|
255
|
+
|
|
256
|
+
self.scene.update()
|
|
257
|
+
|
|
258
|
+
def process_erase_batch_update(self):
|
|
259
|
+
"""Process batched updates from erasing operations"""
|
|
260
|
+
# Update the scene for the entire affected area if needed
|
|
261
|
+
if hasattr(self, 'current_erase_update_rect') and self.current_erase_update_rect is not None:
|
|
262
|
+
# Add a small margin
|
|
263
|
+
update_rect = QRectF(self.current_erase_update_rect)
|
|
264
|
+
update_rect.adjust(-2, -2, 2, 2)
|
|
265
|
+
self.scene.update(update_rect)
|
|
266
|
+
self.current_erase_update_rect = None
|
|
267
|
+
|
|
268
|
+
# Check if we need to update the points overlay
|
|
269
|
+
if hasattr(self, 'points_overlay_needs_update') and self.points_overlay_needs_update:
|
|
270
|
+
# Reset the points pixmap to force a full redraw
|
|
271
|
+
if hasattr(self, 'points_pixmap'):
|
|
272
|
+
self.points_pixmap = None
|
|
273
|
+
if hasattr(self, 'last_rendered_points_count'):
|
|
274
|
+
self.last_rendered_points_count = 0
|
|
275
|
+
self.update_points_overlay()
|
|
276
|
+
self.points_overlay_needs_update = False
|
|
277
|
+
|
|
278
|
+
def end_erase_operation(self):
|
|
279
|
+
self.process_erase_batch_update()
|
|
280
|
+
|
|
281
|
+
def toggle_erase_mode(self, enabled):
|
|
282
|
+
self.erase_mode = enabled
|
|
283
|
+
if enabled:
|
|
284
|
+
self.paint_mode = False
|
|
285
|
+
self.magic_pen_mode = False
|
|
286
|
+
self.setCursor(Qt.CursorShape.CrossCursor)
|
|
287
|
+
self.setDragMode(self.DragMode.NoDrag)
|
|
288
|
+
else:
|
|
289
|
+
self.setCursor(Qt.CursorShape.ArrowCursor)
|
|
290
|
+
self.setDragMode(self.DragMode.ScrollHandDrag)
|