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.
Files changed (99) hide show
  1. PyImageLabeling/__init__.py +22 -0
  2. PyImageLabeling/config.json +289 -0
  3. PyImageLabeling/controller/Controller.py +25 -0
  4. PyImageLabeling/controller/Events.py +147 -0
  5. PyImageLabeling/controller/FileEvents.py +69 -0
  6. PyImageLabeling/controller/ImageEvents.py +32 -0
  7. PyImageLabeling/controller/LabelEvents.py +219 -0
  8. PyImageLabeling/controller/LabelingEvents.py +123 -0
  9. PyImageLabeling/controller/settings/ContourFillinSetting.py +93 -0
  10. PyImageLabeling/controller/settings/CoutourFillingApplyCancel.py +37 -0
  11. PyImageLabeling/controller/settings/EraserSetting.py +73 -0
  12. PyImageLabeling/controller/settings/LabelSetting.py +91 -0
  13. PyImageLabeling/controller/settings/MagicPenSetting.py +125 -0
  14. PyImageLabeling/controller/settings/OpacitySetting.py +66 -0
  15. PyImageLabeling/controller/settings/PaintBrushSetting.py +66 -0
  16. PyImageLabeling/icons/apply.png +0 -0
  17. PyImageLabeling/icons/asterisk-green.png +0 -0
  18. PyImageLabeling/icons/asterisk-red.png +0 -0
  19. PyImageLabeling/icons/back.png +0 -0
  20. PyImageLabeling/icons/border.png +0 -0
  21. PyImageLabeling/icons/cancel.png +0 -0
  22. PyImageLabeling/icons/cleaner.png +0 -0
  23. PyImageLabeling/icons/close.png +0 -0
  24. PyImageLabeling/icons/down.png +0 -0
  25. PyImageLabeling/icons/ellipse.png +0 -0
  26. PyImageLabeling/icons/eraser.png +0 -0
  27. PyImageLabeling/icons/filling.png +0 -0
  28. PyImageLabeling/icons/logoMAIA.png +0 -0
  29. PyImageLabeling/icons/magic.png +0 -0
  30. PyImageLabeling/icons/maia.png +0 -0
  31. PyImageLabeling/icons/maia1.png +0 -0
  32. PyImageLabeling/icons/maia3.ico +0 -0
  33. PyImageLabeling/icons/maia_icon.png +0 -0
  34. PyImageLabeling/icons/move.png +0 -0
  35. PyImageLabeling/icons/opacity.png +0 -0
  36. PyImageLabeling/icons/open_image.png +0 -0
  37. PyImageLabeling/icons/open_layer.png +0 -0
  38. PyImageLabeling/icons/paint.png +0 -0
  39. PyImageLabeling/icons/plus.png +0 -0
  40. PyImageLabeling/icons/polygon.png +0 -0
  41. PyImageLabeling/icons/rectangle.png +0 -0
  42. PyImageLabeling/icons/reset.png +0 -0
  43. PyImageLabeling/icons/save.png +0 -0
  44. PyImageLabeling/icons/setting.png +0 -0
  45. PyImageLabeling/icons/transparency.png:Zone.Identifier +4 -0
  46. PyImageLabeling/icons/up.png +0 -0
  47. PyImageLabeling/icons/visibility.png +0 -0
  48. PyImageLabeling/icons/zoom_minus.png +0 -0
  49. PyImageLabeling/icons/zoom_plus.png +0 -0
  50. PyImageLabeling/model/Core.py +795 -0
  51. PyImageLabeling/model/File/Files.py +166 -0
  52. PyImageLabeling/model/File/NextImage.py +36 -0
  53. PyImageLabeling/model/File/PreviousImage.py +19 -0
  54. PyImageLabeling/model/Image/MoveImage.py +32 -0
  55. PyImageLabeling/model/Image/ResetMoveZoomImage.py +16 -0
  56. PyImageLabeling/model/Image/ZoomMinus.py +25 -0
  57. PyImageLabeling/model/Image/ZoomPlus.py +16 -0
  58. PyImageLabeling/model/Labeling/ClearAll.py +22 -0
  59. PyImageLabeling/model/Labeling/ContourFilling.py +135 -0
  60. PyImageLabeling/model/Labeling/Ellipse.py +350 -0
  61. PyImageLabeling/model/Labeling/Eraser.py +131 -0
  62. PyImageLabeling/model/Labeling/MagicPen.py +131 -0
  63. PyImageLabeling/model/Labeling/PaintBrush.py +207 -0
  64. PyImageLabeling/model/Labeling/Polygon.py +279 -0
  65. PyImageLabeling/model/Labeling/Rectangle.py +248 -0
  66. PyImageLabeling/model/Labeling/Undo.py +12 -0
  67. PyImageLabeling/model/Model.py +40 -0
  68. PyImageLabeling/model/Utils.py +40 -0
  69. PyImageLabeling/old_version/label_rectangle_properties.json +6 -0
  70. PyImageLabeling/old_version/main.py +2073 -0
  71. PyImageLabeling/old_version/models/EraseSettingsDialog.py +51 -0
  72. PyImageLabeling/old_version/models/LabeledRectangle.py +80 -0
  73. PyImageLabeling/old_version/models/MagicSettingsDialog.py +119 -0
  74. PyImageLabeling/old_version/models/OverlayOpacityDialog.py +63 -0
  75. PyImageLabeling/old_version/models/PaintSettingsDialog.py +289 -0
  76. PyImageLabeling/old_version/models/PointItem.py +66 -0
  77. PyImageLabeling/old_version/models/ProcessWorker.py +52 -0
  78. PyImageLabeling/old_version/models/ZoomableGraphicsView.py +1214 -0
  79. PyImageLabeling/old_version/models/tools/ContourTool.py +279 -0
  80. PyImageLabeling/old_version/models/tools/EraserTool.py +290 -0
  81. PyImageLabeling/old_version/models/tools/MagicPenTool.py +199 -0
  82. PyImageLabeling/old_version/models/tools/OverlayTool.py +179 -0
  83. PyImageLabeling/old_version/models/tools/PaintTool.py +68 -0
  84. PyImageLabeling/old_version/models/tools/PolygonTool.py +786 -0
  85. PyImageLabeling/old_version/models/tools/RectangleTool.py +1036 -0
  86. PyImageLabeling/parameters.json +1 -0
  87. PyImageLabeling/style.css +611 -0
  88. PyImageLabeling/view/Builder.py +333 -0
  89. PyImageLabeling/view/QBackgroundItem.py +30 -0
  90. PyImageLabeling/view/QWidgets.py +10 -0
  91. PyImageLabeling/view/View.py +226 -0
  92. PyImageLabeling/view/ZoomableGraphicsView.py +91 -0
  93. PyImageLabeling/view/__init__.py +0 -0
  94. pyimagelabeling-1.0.0.dist-info/METADATA +55 -0
  95. pyimagelabeling-1.0.0.dist-info/RECORD +99 -0
  96. pyimagelabeling-1.0.0.dist-info/WHEEL +5 -0
  97. pyimagelabeling-1.0.0.dist-info/licenses/LICENCE +22 -0
  98. pyimagelabeling-1.0.0.dist-info/top_level.txt +2 -0
  99. 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)