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