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.
- coralnet_toolbox/Explorer/QtDataItem.py +339 -0
- coralnet_toolbox/Explorer/QtExplorer.py +1579 -1006
- coralnet_toolbox/Explorer/QtFeatureStore.py +176 -0
- coralnet_toolbox/Explorer/QtSettingsWidgets.py +212 -25
- coralnet_toolbox/QtEventFilter.py +24 -12
- coralnet_toolbox/QtLabelWindow.py +23 -11
- coralnet_toolbox/QtMainWindow.py +59 -7
- coralnet_toolbox/__init__.py +1 -1
- {coralnet_toolbox-0.0.67.dist-info → coralnet_toolbox-0.0.69.dist-info}/METADATA +14 -7
- {coralnet_toolbox-0.0.67.dist-info → coralnet_toolbox-0.0.69.dist-info}/RECORD +14 -15
- coralnet_toolbox/Explorer/QtAnnotationDataItem.py +0 -97
- coralnet_toolbox/Explorer/QtAnnotationImageWidget.py +0 -183
- coralnet_toolbox/Explorer/QtEmbeddingPointItem.py +0 -30
- {coralnet_toolbox-0.0.67.dist-info → coralnet_toolbox-0.0.69.dist-info}/WHEEL +0 -0
- {coralnet_toolbox-0.0.67.dist-info → coralnet_toolbox-0.0.69.dist-info}/entry_points.txt +0 -0
- {coralnet_toolbox-0.0.67.dist-info → coralnet_toolbox-0.0.69.dist-info}/licenses/LICENSE.txt +0 -0
- {coralnet_toolbox-0.0.67.dist-info → coralnet_toolbox-0.0.69.dist-info}/top_level.txt +0 -0
@@ -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
|