coralnet-toolbox 0.0.67__py2.py3-none-any.whl → 0.0.68__py2.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.
@@ -0,0 +1,300 @@
1
+ import warnings
2
+
3
+ import os
4
+
5
+ from PyQt5.QtCore import Qt, QTimer
6
+ from PyQt5.QtGui import QPen, QColor, QPainter
7
+ from PyQt5.QtWidgets import QGraphicsEllipseItem, QStyle, QVBoxLayout, QLabel, QWidget, QGraphicsItem
8
+
9
+ warnings.filterwarnings("ignore", category=DeprecationWarning)
10
+
11
+
12
+ # ----------------------------------------------------------------------------------------------------------------------
13
+ # Constants
14
+ # ----------------------------------------------------------------------------------------------------------------------
15
+
16
+ POINT_SIZE = 15
17
+ POINT_WIDTH = 3
18
+
19
+ ANNOTATION_WIDTH = 5
20
+
21
+
22
+ # ----------------------------------------------------------------------------------------------------------------------
23
+ # Classes
24
+ # ----------------------------------------------------------------------------------------------------------------------
25
+
26
+
27
+ class EmbeddingPointItem(QGraphicsEllipseItem):
28
+ """
29
+ A custom QGraphicsEllipseItem that gets its state and appearance
30
+ directly from an associated AnnotationDataItem.
31
+ """
32
+
33
+ def __init__(self, data_item):
34
+ """
35
+ Initializes the point item.
36
+
37
+ Args:
38
+ data_item (AnnotationDataItem): The data item that holds the state
39
+ for this point.
40
+ """
41
+ # Initialize the ellipse with a placeholder rectangle; its position will be set later.
42
+ super(EmbeddingPointItem, self).__init__(0, 0, POINT_SIZE, POINT_SIZE)
43
+
44
+ # Store a direct reference to the data item
45
+ self.data_item = data_item
46
+
47
+ # Set the item's flags
48
+ self.setFlag(QGraphicsItem.ItemIsSelectable, True)
49
+ self.setFlag(QGraphicsItem.ItemIgnoresTransformations, True)
50
+
51
+ # Set initial appearance from the data_item
52
+ self.setPen(QPen(QColor("black"), POINT_WIDTH))
53
+ self.setBrush(self.data_item.effective_color)
54
+
55
+ # Set the position of the point based on the data item's embedding coordinates
56
+ self.setPos(self.data_item.embedding_x, self.data_item.embedding_y)
57
+
58
+ def paint(self, painter, option, widget):
59
+ """
60
+ Custom paint method to ensure the point's color is always in sync with
61
+ the AnnotationDataItem and to prevent the default selection box from
62
+ being drawn.
63
+ """
64
+ # Dynamically get the latest color from the central data item.
65
+ # This ensures that preview color changes are reflected instantly.
66
+ self.setBrush(self.data_item.effective_color)
67
+
68
+ # Remove the 'State_Selected' flag from the style options before painting.
69
+ # This is the key to preventing Qt from drawing the default dotted
70
+ # selection rectangle around the item.
71
+ option.state &= ~QStyle.State_Selected
72
+
73
+ # Call the base class's paint method to draw the ellipse
74
+ super(EmbeddingPointItem, self).paint(painter, option, widget)
75
+
76
+
77
+ class AnnotationImageWidget(QWidget):
78
+ """Widget to display a single annotation image crop with selection support."""
79
+
80
+ def __init__(self, data_item, widget_height=96, annotation_viewer=None, parent=None):
81
+ super(AnnotationImageWidget, self).__init__(parent)
82
+ self.data_item = data_item # The single source of truth for state
83
+ self.annotation = data_item.annotation
84
+ self.annotation_viewer = annotation_viewer
85
+
86
+ self.widget_height = widget_height
87
+ self.aspect_ratio = 1.0
88
+ self.pixmap = None
89
+
90
+ self.animation_offset = 0
91
+ self.animation_timer = QTimer(self)
92
+ self.animation_timer.timeout.connect(self._update_animation_frame)
93
+ self.animation_timer.setInterval(75)
94
+
95
+ self.setup_ui()
96
+ self.load_and_set_image()
97
+
98
+ def setup_ui(self):
99
+ """Set up the basic UI with a label for the image."""
100
+ layout = QVBoxLayout(self)
101
+ layout.setContentsMargins(4, 4, 4, 4)
102
+ self.image_label = QLabel()
103
+ self.image_label.setAlignment(Qt.AlignCenter)
104
+ self.image_label.setScaledContents(True)
105
+ self.image_label.setStyleSheet("border: none;")
106
+ layout.addWidget(self.image_label)
107
+
108
+ def load_and_set_image(self):
109
+ """Load image, calculate its aspect ratio, and set the widget's initial size."""
110
+ try:
111
+ cropped_pixmap = self.annotation.get_cropped_image_graphic()
112
+ if cropped_pixmap and not cropped_pixmap.isNull():
113
+ self.pixmap = cropped_pixmap
114
+ if self.pixmap.height() > 0:
115
+ self.aspect_ratio = self.pixmap.width() / self.pixmap.height()
116
+ else:
117
+ self.aspect_ratio = 1.0
118
+ else:
119
+ self.image_label.setText("No Image\nAvailable")
120
+ self.pixmap = None
121
+ self.aspect_ratio = 1.0
122
+ except Exception as e:
123
+ print(f"Error loading annotation image: {e}")
124
+ self.image_label.setText("Error\nLoading Image")
125
+ self.pixmap = None
126
+ self.aspect_ratio = 1.0
127
+ self.update_height(self.widget_height)
128
+
129
+ def update_height(self, new_height):
130
+ """Updates the widget's height and rescales its width and content accordingly."""
131
+ self.widget_height = new_height
132
+ new_width = int(self.widget_height * self.aspect_ratio)
133
+ self.setFixedSize(new_width, new_height)
134
+ if self.pixmap:
135
+ scaled_pixmap = self.pixmap.scaled(
136
+ new_width - 8,
137
+ new_height - 8,
138
+ Qt.KeepAspectRatio,
139
+ Qt.SmoothTransformation
140
+ )
141
+ self.image_label.setPixmap(scaled_pixmap)
142
+ self.update()
143
+
144
+ def update_selection_visuals(self):
145
+ """
146
+ Updates the widget's visual state based on the data_item's selection
147
+ status. This should be called by the controlling viewer.
148
+ """
149
+ is_selected = self.data_item.is_selected
150
+
151
+ if is_selected:
152
+ if not self.animation_timer.isActive():
153
+ self.animation_timer.start()
154
+ else:
155
+ if self.animation_timer.isActive():
156
+ self.animation_timer.stop()
157
+ self.animation_offset = 0
158
+
159
+ # Trigger a repaint to show the new selection state (border, etc.)
160
+ self.update()
161
+
162
+ def is_selected(self):
163
+ """Return whether this widget is selected via the data item."""
164
+ return self.data_item.is_selected
165
+
166
+ def _update_animation_frame(self):
167
+ """Update the animation offset and schedule a repaint."""
168
+ self.animation_offset = (self.animation_offset + 1) % 20
169
+ self.update()
170
+
171
+ def paintEvent(self, event):
172
+ """Handle custom drawing for the widget, including the selection border."""
173
+ super().paintEvent(event)
174
+ painter = QPainter(self)
175
+ painter.setRenderHint(QPainter.Antialiasing)
176
+
177
+ effective_label = self.data_item.effective_label
178
+ pen_color = self.data_item.effective_color
179
+ if effective_label and effective_label.id == "-1":
180
+ # If the label is a temporary one (e.g., "-1", Review), use black for the pen color
181
+ pen_color = QColor("black")
182
+
183
+ if self.is_selected():
184
+ pen = QPen(pen_color, ANNOTATION_WIDTH)
185
+ pen.setStyle(Qt.CustomDashLine)
186
+ pen.setDashPattern([2, 3])
187
+ pen.setDashOffset(self.animation_offset)
188
+ else:
189
+ pen = QPen(pen_color, ANNOTATION_WIDTH)
190
+ pen.setStyle(Qt.SolidLine)
191
+
192
+ painter.setPen(pen)
193
+ painter.setBrush(Qt.NoBrush)
194
+
195
+ width = pen.width()
196
+ half_width = (width - 1) // 2
197
+ rect = self.rect().adjusted(half_width, half_width, -half_width, -half_width)
198
+ painter.drawRect(rect)
199
+
200
+ def mousePressEvent(self, event):
201
+ """Handle mouse press events for selection, delegating logic to the viewer."""
202
+ if event.button() == Qt.LeftButton:
203
+ if self.annotation_viewer and hasattr(self.annotation_viewer, 'handle_annotation_selection'):
204
+ # The viewer is the controller and will decide how to change the selection state
205
+ self.annotation_viewer.handle_annotation_selection(self, event)
206
+ elif event.button() == Qt.RightButton:
207
+ event.ignore()
208
+ return
209
+ super().mousePressEvent(event)
210
+
211
+
212
+ class AnnotationDataItem:
213
+ """
214
+ Holds all annotation state information for consistent display across viewers.
215
+ This acts as the "ViewModel" for a single annotation, serving as the single
216
+ source of truth for its state in the UI.
217
+ """
218
+
219
+ def __init__(self, annotation, embedding_x=None, embedding_y=None, embedding_id=None):
220
+ self.annotation = annotation
221
+ self.embedding_x = embedding_x if embedding_x is not None else 0.0
222
+ self.embedding_y = embedding_y if embedding_y is not None else 0.0
223
+ self.embedding_id = embedding_id if embedding_id is not None else 0
224
+ self._is_selected = False
225
+ self._preview_label = None
226
+ self._original_label = annotation.label
227
+ self._marked_for_deletion = False
228
+
229
+ @property
230
+ def effective_label(self):
231
+ """Get the current effective label (preview if it exists, otherwise original)."""
232
+ return self._preview_label if self._preview_label else self.annotation.label
233
+
234
+ @property
235
+ def effective_color(self):
236
+ """Get the effective color for this annotation based on the effective label."""
237
+ return self.effective_label.color
238
+
239
+ @property
240
+ def is_selected(self):
241
+ """Check if this annotation is selected."""
242
+ return self._is_selected
243
+
244
+ def set_selected(self, selected):
245
+ """Set the selection state. This is the single point of control."""
246
+ self._is_selected = selected
247
+
248
+ def set_preview_label(self, label):
249
+ """Set a preview label for this annotation."""
250
+ self._preview_label = label
251
+
252
+ def clear_preview_label(self):
253
+ """Clear the preview label and revert to the original."""
254
+ self._preview_label = None
255
+
256
+ def has_preview_changes(self):
257
+ """Check if this annotation has a temporary preview label assigned."""
258
+ return self._preview_label is not None
259
+
260
+ def mark_for_deletion(self):
261
+ """Mark this annotation for deletion."""
262
+ self._marked_for_deletion = True
263
+
264
+ def unmark_for_deletion(self):
265
+ """Unmark this annotation for deletion."""
266
+ self._marked_for_deletion = False
267
+
268
+ def is_marked_for_deletion(self):
269
+ """Check if this annotation is marked for deletion."""
270
+ return self._marked_for_deletion
271
+
272
+ def apply_preview_permanently(self):
273
+ """Apply the preview label permanently to the underlying annotation object."""
274
+ if self._preview_label:
275
+ self.annotation.update_label(self._preview_label)
276
+ self.annotation.update_user_confidence(self._preview_label)
277
+ self._original_label = self._preview_label
278
+ self._preview_label = None
279
+ return True
280
+ return False
281
+
282
+ def get_display_info(self):
283
+ """Get display information for this annotation."""
284
+ return {
285
+ 'id': self.annotation.id,
286
+ 'label': self.effective_label.short_label_code,
287
+ 'confidence': self.get_effective_confidence(),
288
+ 'type': type(self.annotation).__name__,
289
+ 'image': os.path.basename(self.annotation.image_path),
290
+ 'embedding_id': self.embedding_id,
291
+ 'color': self.effective_color
292
+ }
293
+
294
+ def get_effective_confidence(self):
295
+ """Get the effective confidence value."""
296
+ if self.annotation.verified and hasattr(self.annotation, 'user_confidence') and self.annotation.user_confidence:
297
+ return list(self.annotation.user_confidence.values())[0]
298
+ elif hasattr(self.annotation, 'machine_confidence') and self.annotation.machine_confidence:
299
+ return list(self.annotation.machine_confidence.values())[0]
300
+ return 0.0