labelimgplusplus 2.0.0a0__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.
- labelimgplusplus-2.0.0a0.dist-info/LICENSE +9 -0
- labelimgplusplus-2.0.0a0.dist-info/METADATA +282 -0
- labelimgplusplus-2.0.0a0.dist-info/RECORD +30 -0
- labelimgplusplus-2.0.0a0.dist-info/WHEEL +5 -0
- labelimgplusplus-2.0.0a0.dist-info/entry_points.txt +2 -0
- labelimgplusplus-2.0.0a0.dist-info/top_level.txt +1 -0
- libs/__init__.py +2 -0
- libs/canvas.py +748 -0
- libs/colorDialog.py +37 -0
- libs/combobox.py +33 -0
- libs/commands.py +328 -0
- libs/constants.py +26 -0
- libs/create_ml_io.py +135 -0
- libs/default_label_combobox.py +27 -0
- libs/galleryWidget.py +568 -0
- libs/hashableQListWidgetItem.py +28 -0
- libs/labelDialog.py +95 -0
- libs/labelFile.py +174 -0
- libs/lightWidget.py +33 -0
- libs/pascal_voc_io.py +171 -0
- libs/resources.py +4212 -0
- libs/settings.py +45 -0
- libs/shape.py +209 -0
- libs/stringBundle.py +78 -0
- libs/styles.py +82 -0
- libs/toolBar.py +275 -0
- libs/ustr.py +17 -0
- libs/utils.py +119 -0
- libs/yolo_io.py +143 -0
- libs/zoomWidget.py +26 -0
libs/galleryWidget.py
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
# libs/galleryWidget.py
|
|
2
|
+
"""Gallery view widget for image thumbnail display with annotation status."""
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
from PyQt5.QtGui import QPixmap, QImage, QPainter, QColor, QPen, QImageReader, QIcon, QBrush, QPolygonF
|
|
6
|
+
from PyQt5.QtCore import Qt, QSize, QObject, pyqtSignal, QRunnable, QThreadPool, QTimer, QPointF
|
|
7
|
+
from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem,
|
|
8
|
+
QListView, QSlider, QLabel)
|
|
9
|
+
except ImportError:
|
|
10
|
+
from PyQt4.QtGui import (QPixmap, QImage, QPainter, QColor, QPen, QImageReader, QIcon, QBrush,
|
|
11
|
+
QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem,
|
|
12
|
+
QListView, QSlider, QLabel, QPolygonF)
|
|
13
|
+
from PyQt4.QtCore import Qt, QSize, QObject, pyqtSignal, QRunnable, QThreadPool, QPointF
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import hashlib
|
|
17
|
+
from collections import OrderedDict
|
|
18
|
+
from enum import IntEnum
|
|
19
|
+
try:
|
|
20
|
+
from xml.etree import ElementTree
|
|
21
|
+
except ImportError:
|
|
22
|
+
ElementTree = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def generate_color_by_text(text):
|
|
26
|
+
"""Generate a consistent color based on text hash."""
|
|
27
|
+
hash_val = int(hashlib.md5(text.encode('utf-8')).hexdigest()[:8], 16)
|
|
28
|
+
r = (hash_val & 0xFF0000) >> 16
|
|
29
|
+
g = (hash_val & 0x00FF00) >> 8
|
|
30
|
+
b = hash_val & 0x0000FF
|
|
31
|
+
# Ensure colors are bright enough
|
|
32
|
+
r = max(100, r)
|
|
33
|
+
g = max(100, g)
|
|
34
|
+
b = max(100, b)
|
|
35
|
+
return QColor(r, g, b)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_yolo_annotations(txt_path, classes_path=None):
|
|
39
|
+
"""Parse YOLO format annotations.
|
|
40
|
+
|
|
41
|
+
Returns list of (label, normalized_bbox) where bbox is (x_center, y_center, w, h).
|
|
42
|
+
"""
|
|
43
|
+
annotations = []
|
|
44
|
+
if not os.path.isfile(txt_path):
|
|
45
|
+
return annotations
|
|
46
|
+
|
|
47
|
+
# Load class names
|
|
48
|
+
classes = []
|
|
49
|
+
if classes_path and os.path.isfile(classes_path):
|
|
50
|
+
with open(classes_path, 'r') as f:
|
|
51
|
+
classes = [line.strip() for line in f if line.strip()]
|
|
52
|
+
|
|
53
|
+
with open(txt_path, 'r') as f:
|
|
54
|
+
for line in f:
|
|
55
|
+
parts = line.strip().split()
|
|
56
|
+
if len(parts) >= 5:
|
|
57
|
+
class_idx = int(parts[0])
|
|
58
|
+
x_center = float(parts[1])
|
|
59
|
+
y_center = float(parts[2])
|
|
60
|
+
w = float(parts[3])
|
|
61
|
+
h = float(parts[4])
|
|
62
|
+
label = classes[class_idx] if class_idx < len(classes) else f"class_{class_idx}"
|
|
63
|
+
annotations.append((label, (x_center, y_center, w, h)))
|
|
64
|
+
return annotations
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_voc_annotations(xml_path):
|
|
68
|
+
"""Parse Pascal VOC format annotations.
|
|
69
|
+
|
|
70
|
+
Returns list of (label, normalized_bbox) where bbox is (x_center, y_center, w, h).
|
|
71
|
+
"""
|
|
72
|
+
annotations = []
|
|
73
|
+
if not os.path.isfile(xml_path) or ElementTree is None:
|
|
74
|
+
return annotations
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
tree = ElementTree.parse(xml_path)
|
|
78
|
+
root = tree.getroot()
|
|
79
|
+
|
|
80
|
+
# Get image size for normalization
|
|
81
|
+
size_elem = root.find('size')
|
|
82
|
+
if size_elem is None:
|
|
83
|
+
return annotations
|
|
84
|
+
img_w = int(size_elem.find('width').text)
|
|
85
|
+
img_h = int(size_elem.find('height').text)
|
|
86
|
+
|
|
87
|
+
if img_w <= 0 or img_h <= 0:
|
|
88
|
+
return annotations
|
|
89
|
+
|
|
90
|
+
for obj in root.iter('object'):
|
|
91
|
+
label = obj.find('name').text
|
|
92
|
+
bbox = obj.find('bndbox')
|
|
93
|
+
xmin = float(bbox.find('xmin').text)
|
|
94
|
+
ymin = float(bbox.find('ymin').text)
|
|
95
|
+
xmax = float(bbox.find('xmax').text)
|
|
96
|
+
ymax = float(bbox.find('ymax').text)
|
|
97
|
+
|
|
98
|
+
# Convert to normalized center format
|
|
99
|
+
x_center = (xmin + xmax) / 2 / img_w
|
|
100
|
+
y_center = (ymin + ymax) / 2 / img_h
|
|
101
|
+
w = (xmax - xmin) / img_w
|
|
102
|
+
h = (ymax - ymin) / img_h
|
|
103
|
+
annotations.append((label, (x_center, y_center, w, h)))
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
return annotations
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def find_annotation_file(image_path, save_dir=None):
|
|
111
|
+
"""Find annotation file for an image.
|
|
112
|
+
|
|
113
|
+
Returns (annotation_path, format) or (None, None) if not found.
|
|
114
|
+
Format is 'yolo', 'voc', or 'createml'.
|
|
115
|
+
"""
|
|
116
|
+
base = os.path.splitext(os.path.basename(image_path))[0]
|
|
117
|
+
img_dir = os.path.dirname(image_path)
|
|
118
|
+
|
|
119
|
+
# Directories to search
|
|
120
|
+
search_dirs = [img_dir]
|
|
121
|
+
if save_dir and save_dir != img_dir:
|
|
122
|
+
search_dirs.append(save_dir)
|
|
123
|
+
|
|
124
|
+
# Check for YOLO format (.txt)
|
|
125
|
+
for search_dir in search_dirs:
|
|
126
|
+
txt_path = os.path.join(search_dir, base + '.txt')
|
|
127
|
+
if os.path.isfile(txt_path):
|
|
128
|
+
# Find classes.txt
|
|
129
|
+
classes_path = os.path.join(search_dir, 'classes.txt')
|
|
130
|
+
if not os.path.isfile(classes_path):
|
|
131
|
+
classes_path = os.path.join(img_dir, 'classes.txt')
|
|
132
|
+
return txt_path, 'yolo', classes_path if os.path.isfile(classes_path) else None
|
|
133
|
+
|
|
134
|
+
# Check for Pascal VOC format (.xml)
|
|
135
|
+
for search_dir in search_dirs:
|
|
136
|
+
xml_path = os.path.join(search_dir, base + '.xml')
|
|
137
|
+
if os.path.isfile(xml_path):
|
|
138
|
+
return xml_path, 'voc', None
|
|
139
|
+
|
|
140
|
+
return None, None, None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class AnnotationStatus(IntEnum):
|
|
144
|
+
"""Enum representing annotation status of an image."""
|
|
145
|
+
NO_LABELS = 0 # Gray border
|
|
146
|
+
HAS_LABELS = 1 # Blue border
|
|
147
|
+
VERIFIED = 2 # Green border
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class ThumbnailCache:
|
|
151
|
+
"""LRU cache for thumbnail images with O(1) operations using OrderedDict."""
|
|
152
|
+
|
|
153
|
+
def __init__(self, max_size=200):
|
|
154
|
+
self.max_size = max_size
|
|
155
|
+
self._cache = OrderedDict()
|
|
156
|
+
|
|
157
|
+
def get(self, path):
|
|
158
|
+
"""Retrieve thumbnail from cache (O(1) with LRU update)."""
|
|
159
|
+
if path in self._cache:
|
|
160
|
+
self._cache.move_to_end(path) # O(1) instead of O(n)
|
|
161
|
+
return self._cache[path]
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
def put(self, path, pixmap):
|
|
165
|
+
"""Store thumbnail in cache with O(1) LRU eviction."""
|
|
166
|
+
if path in self._cache:
|
|
167
|
+
self._cache.move_to_end(path) # O(1)
|
|
168
|
+
self._cache[path] = pixmap
|
|
169
|
+
else:
|
|
170
|
+
if len(self._cache) >= self.max_size:
|
|
171
|
+
self._cache.popitem(last=False) # O(1) eviction
|
|
172
|
+
self._cache[path] = pixmap
|
|
173
|
+
|
|
174
|
+
def clear(self):
|
|
175
|
+
"""Clear all cached thumbnails."""
|
|
176
|
+
self._cache.clear()
|
|
177
|
+
|
|
178
|
+
def remove(self, path):
|
|
179
|
+
"""Remove specific thumbnail from cache."""
|
|
180
|
+
self._cache.pop(path, None) # O(1)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class ThumbnailLoaderSignals(QObject):
|
|
184
|
+
"""Signals for async thumbnail loading."""
|
|
185
|
+
thumbnail_ready = pyqtSignal(str, QImage) # path, image
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class ThumbnailLoaderWorker(QRunnable):
|
|
189
|
+
"""Worker for async thumbnail generation with annotation overlay."""
|
|
190
|
+
|
|
191
|
+
def __init__(self, image_path, size=100, save_dir=None):
|
|
192
|
+
super().__init__()
|
|
193
|
+
self.image_path = image_path
|
|
194
|
+
self.size = size
|
|
195
|
+
self.save_dir = save_dir
|
|
196
|
+
self.signals = ThumbnailLoaderSignals()
|
|
197
|
+
|
|
198
|
+
def run(self):
|
|
199
|
+
"""Load, scale image, and draw annotations in background thread."""
|
|
200
|
+
try:
|
|
201
|
+
reader = QImageReader(self.image_path)
|
|
202
|
+
reader.setAutoTransform(True)
|
|
203
|
+
|
|
204
|
+
original_size = reader.size()
|
|
205
|
+
if original_size.isValid():
|
|
206
|
+
scaled_size = original_size.scaled(
|
|
207
|
+
self.size, self.size,
|
|
208
|
+
Qt.KeepAspectRatio
|
|
209
|
+
)
|
|
210
|
+
reader.setScaledSize(scaled_size)
|
|
211
|
+
|
|
212
|
+
image = reader.read()
|
|
213
|
+
if not image.isNull():
|
|
214
|
+
# Draw annotations on thumbnail
|
|
215
|
+
image = self._draw_annotations(image)
|
|
216
|
+
self.signals.thumbnail_ready.emit(self.image_path, image)
|
|
217
|
+
except Exception:
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
def _draw_annotations(self, image):
|
|
221
|
+
"""Draw bounding boxes on the thumbnail image."""
|
|
222
|
+
# Find annotation file
|
|
223
|
+
ann_path, ann_format, classes_path = find_annotation_file(
|
|
224
|
+
self.image_path, self.save_dir
|
|
225
|
+
)
|
|
226
|
+
if not ann_path:
|
|
227
|
+
return image
|
|
228
|
+
|
|
229
|
+
# Parse annotations
|
|
230
|
+
if ann_format == 'yolo':
|
|
231
|
+
annotations = parse_yolo_annotations(ann_path, classes_path)
|
|
232
|
+
elif ann_format == 'voc':
|
|
233
|
+
annotations = parse_voc_annotations(ann_path)
|
|
234
|
+
else:
|
|
235
|
+
return image
|
|
236
|
+
|
|
237
|
+
if not annotations:
|
|
238
|
+
return image
|
|
239
|
+
|
|
240
|
+
# Draw on image
|
|
241
|
+
img_w = image.width()
|
|
242
|
+
img_h = image.height()
|
|
243
|
+
|
|
244
|
+
painter = QPainter(image)
|
|
245
|
+
painter.setRenderHint(QPainter.Antialiasing)
|
|
246
|
+
|
|
247
|
+
for label, bbox in annotations:
|
|
248
|
+
x_center, y_center, w, h = bbox
|
|
249
|
+
|
|
250
|
+
# Convert normalized coords to pixel coords
|
|
251
|
+
x1 = int((x_center - w / 2) * img_w)
|
|
252
|
+
y1 = int((y_center - h / 2) * img_h)
|
|
253
|
+
x2 = int((x_center + w / 2) * img_w)
|
|
254
|
+
y2 = int((y_center + h / 2) * img_h)
|
|
255
|
+
|
|
256
|
+
# Get color for this label
|
|
257
|
+
color = generate_color_by_text(label)
|
|
258
|
+
pen = QPen(color)
|
|
259
|
+
pen.setWidth(2)
|
|
260
|
+
painter.setPen(pen)
|
|
261
|
+
|
|
262
|
+
# Draw rectangle
|
|
263
|
+
painter.drawRect(x1, y1, x2 - x1, y2 - y1)
|
|
264
|
+
|
|
265
|
+
painter.end()
|
|
266
|
+
return image
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class GalleryWidget(QWidget):
|
|
270
|
+
"""Gallery widget using QListWidget in IconMode for tiled layout."""
|
|
271
|
+
|
|
272
|
+
image_selected = pyqtSignal(str) # Single click
|
|
273
|
+
image_activated = pyqtSignal(str) # Double click
|
|
274
|
+
|
|
275
|
+
DEFAULT_ICON_SIZE = 100
|
|
276
|
+
MIN_ICON_SIZE = 40
|
|
277
|
+
MAX_ICON_SIZE = 300
|
|
278
|
+
|
|
279
|
+
STATUS_COLORS = {
|
|
280
|
+
AnnotationStatus.NO_LABELS: QColor(150, 150, 150), # Gray
|
|
281
|
+
AnnotationStatus.HAS_LABELS: QColor(66, 133, 244), # Blue
|
|
282
|
+
AnnotationStatus.VERIFIED: QColor(52, 168, 83), # Green
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
def __init__(self, parent=None, show_size_slider=False):
|
|
286
|
+
super().__init__(parent)
|
|
287
|
+
|
|
288
|
+
self._icon_size = self.DEFAULT_ICON_SIZE
|
|
289
|
+
self._show_size_slider = show_size_slider
|
|
290
|
+
self._save_dir = None # Directory where annotations are saved
|
|
291
|
+
|
|
292
|
+
self.thumbnail_cache = ThumbnailCache(max_size=300)
|
|
293
|
+
self.thread_pool = QThreadPool.globalInstance()
|
|
294
|
+
self.thread_pool.setMaxThreadCount(4)
|
|
295
|
+
|
|
296
|
+
self._path_to_item = {}
|
|
297
|
+
self._image_list = []
|
|
298
|
+
self._loading_paths = set()
|
|
299
|
+
self._statuses = {}
|
|
300
|
+
self._loading_thumbnails = False # Guard against re-entrant calls
|
|
301
|
+
|
|
302
|
+
self._setup_ui()
|
|
303
|
+
|
|
304
|
+
def _setup_ui(self):
|
|
305
|
+
"""Initialize UI components."""
|
|
306
|
+
self.list_widget = QListWidget(self)
|
|
307
|
+
self.list_widget.setViewMode(QListView.IconMode)
|
|
308
|
+
self._apply_icon_size()
|
|
309
|
+
self.list_widget.setResizeMode(QListView.Adjust)
|
|
310
|
+
self.list_widget.setWrapping(True)
|
|
311
|
+
self.list_widget.setSpacing(5)
|
|
312
|
+
self.list_widget.setMovement(QListView.Static)
|
|
313
|
+
self.list_widget.setUniformItemSizes(True)
|
|
314
|
+
self.list_widget.setWordWrap(True)
|
|
315
|
+
|
|
316
|
+
self.list_widget.itemClicked.connect(self._on_item_clicked)
|
|
317
|
+
self.list_widget.itemDoubleClicked.connect(self._on_item_double_clicked)
|
|
318
|
+
self.list_widget.verticalScrollBar().valueChanged.connect(self._on_scroll)
|
|
319
|
+
|
|
320
|
+
layout = QVBoxLayout(self)
|
|
321
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
322
|
+
|
|
323
|
+
# Add size slider if enabled
|
|
324
|
+
if self._show_size_slider:
|
|
325
|
+
slider_layout = QHBoxLayout()
|
|
326
|
+
slider_layout.setContentsMargins(10, 5, 10, 5)
|
|
327
|
+
|
|
328
|
+
self.size_label = QLabel("Size:")
|
|
329
|
+
slider_layout.addWidget(self.size_label)
|
|
330
|
+
|
|
331
|
+
self.size_slider = QSlider(Qt.Horizontal)
|
|
332
|
+
self.size_slider.setMinimum(self.MIN_ICON_SIZE)
|
|
333
|
+
self.size_slider.setMaximum(self.MAX_ICON_SIZE)
|
|
334
|
+
self.size_slider.setValue(self._icon_size)
|
|
335
|
+
self.size_slider.setTickPosition(QSlider.TicksBelow)
|
|
336
|
+
self.size_slider.setTickInterval(20)
|
|
337
|
+
self.size_slider.valueChanged.connect(self._on_size_changed)
|
|
338
|
+
slider_layout.addWidget(self.size_slider)
|
|
339
|
+
|
|
340
|
+
self.size_value_label = QLabel(f"{self._icon_size}px")
|
|
341
|
+
self.size_value_label.setMinimumWidth(45)
|
|
342
|
+
slider_layout.addWidget(self.size_value_label)
|
|
343
|
+
|
|
344
|
+
layout.addLayout(slider_layout)
|
|
345
|
+
|
|
346
|
+
layout.addWidget(self.list_widget)
|
|
347
|
+
|
|
348
|
+
def _apply_icon_size(self):
|
|
349
|
+
"""Apply current icon size to list widget."""
|
|
350
|
+
grid_size = self._icon_size + 20
|
|
351
|
+
self.list_widget.setIconSize(QSize(self._icon_size, self._icon_size))
|
|
352
|
+
self.list_widget.setGridSize(QSize(grid_size, grid_size + 20))
|
|
353
|
+
|
|
354
|
+
def _on_size_changed(self, value):
|
|
355
|
+
"""Handle size slider change."""
|
|
356
|
+
self._icon_size = value
|
|
357
|
+
if hasattr(self, 'size_value_label'):
|
|
358
|
+
self.size_value_label.setText(f"{value}px")
|
|
359
|
+
self._apply_icon_size()
|
|
360
|
+
# Clear cache and reload thumbnails at new size
|
|
361
|
+
self.thumbnail_cache.clear()
|
|
362
|
+
self._loading_paths.clear()
|
|
363
|
+
self._reload_all_thumbnails()
|
|
364
|
+
|
|
365
|
+
def _reload_all_thumbnails(self):
|
|
366
|
+
"""Reload all thumbnails at current size."""
|
|
367
|
+
for path, item in self._path_to_item.items():
|
|
368
|
+
# Set placeholder
|
|
369
|
+
placeholder = QPixmap(self._icon_size, self._icon_size)
|
|
370
|
+
placeholder.fill(QColor(220, 220, 220))
|
|
371
|
+
item.setIcon(QIcon(placeholder))
|
|
372
|
+
item.setSizeHint(QSize(self._icon_size + 20, self._icon_size + 40))
|
|
373
|
+
self._load_visible_thumbnails()
|
|
374
|
+
|
|
375
|
+
def set_image_list(self, image_paths):
|
|
376
|
+
"""Populate gallery with images."""
|
|
377
|
+
self.clear()
|
|
378
|
+
self._image_list = list(image_paths)
|
|
379
|
+
|
|
380
|
+
for path in image_paths:
|
|
381
|
+
self._add_item(path)
|
|
382
|
+
|
|
383
|
+
# Defer thumbnail loading to next event loop cycle to prevent blocking
|
|
384
|
+
QTimer.singleShot(0, self._load_visible_thumbnails)
|
|
385
|
+
|
|
386
|
+
def _add_item(self, image_path):
|
|
387
|
+
"""Add an item to the list widget."""
|
|
388
|
+
filename = os.path.basename(image_path)
|
|
389
|
+
if len(filename) > 12:
|
|
390
|
+
display_name = filename[:10] + "..."
|
|
391
|
+
else:
|
|
392
|
+
display_name = filename
|
|
393
|
+
|
|
394
|
+
item = QListWidgetItem(display_name)
|
|
395
|
+
item.setToolTip(filename)
|
|
396
|
+
grid_size = self._icon_size + 20
|
|
397
|
+
item.setSizeHint(QSize(grid_size, grid_size + 20))
|
|
398
|
+
|
|
399
|
+
# Set placeholder icon
|
|
400
|
+
placeholder = QPixmap(self._icon_size, self._icon_size)
|
|
401
|
+
placeholder.fill(QColor(220, 220, 220))
|
|
402
|
+
item.setIcon(QIcon(placeholder))
|
|
403
|
+
|
|
404
|
+
# Set initial status color (gray background)
|
|
405
|
+
item.setBackground(QBrush(QColor(240, 240, 240)))
|
|
406
|
+
|
|
407
|
+
# Store path in item's data
|
|
408
|
+
item.setData(Qt.UserRole, image_path)
|
|
409
|
+
|
|
410
|
+
self.list_widget.addItem(item)
|
|
411
|
+
self._path_to_item[image_path] = item
|
|
412
|
+
|
|
413
|
+
def _on_scroll(self):
|
|
414
|
+
"""Handle scroll to load visible thumbnails."""
|
|
415
|
+
self._load_visible_thumbnails()
|
|
416
|
+
|
|
417
|
+
def _load_visible_thumbnails(self):
|
|
418
|
+
"""Load thumbnails for visible items."""
|
|
419
|
+
# Guard against re-entrant calls during layout/scroll cascades
|
|
420
|
+
if self._loading_thumbnails:
|
|
421
|
+
return
|
|
422
|
+
self._loading_thumbnails = True
|
|
423
|
+
try:
|
|
424
|
+
viewport_rect = self.list_widget.viewport().rect()
|
|
425
|
+
count = self.list_widget.count()
|
|
426
|
+
|
|
427
|
+
for i in range(count):
|
|
428
|
+
item = self.list_widget.item(i)
|
|
429
|
+
item_rect = self.list_widget.visualItemRect(item)
|
|
430
|
+
|
|
431
|
+
# Check if item is visible (with some buffer)
|
|
432
|
+
if item_rect.intersects(viewport_rect.adjusted(0, -200, 0, 200)):
|
|
433
|
+
path = item.data(Qt.UserRole)
|
|
434
|
+
if path and path not in self._loading_paths:
|
|
435
|
+
cached = self.thumbnail_cache.get(path)
|
|
436
|
+
if cached:
|
|
437
|
+
self._set_item_icon(item, cached, path)
|
|
438
|
+
else:
|
|
439
|
+
self._load_thumbnail_async(path)
|
|
440
|
+
finally:
|
|
441
|
+
self._loading_thumbnails = False
|
|
442
|
+
|
|
443
|
+
def _load_thumbnail_async(self, image_path):
|
|
444
|
+
"""Load thumbnail in background thread."""
|
|
445
|
+
if image_path in self._loading_paths:
|
|
446
|
+
return
|
|
447
|
+
|
|
448
|
+
self._loading_paths.add(image_path)
|
|
449
|
+
worker = ThumbnailLoaderWorker(image_path, self._icon_size, self._save_dir)
|
|
450
|
+
worker.signals.thumbnail_ready.connect(self._on_thumbnail_loaded)
|
|
451
|
+
self.thread_pool.start(worker)
|
|
452
|
+
|
|
453
|
+
def _on_thumbnail_loaded(self, path, image):
|
|
454
|
+
"""Handle loaded thumbnail."""
|
|
455
|
+
self._loading_paths.discard(path)
|
|
456
|
+
pixmap = QPixmap.fromImage(image)
|
|
457
|
+
self.thumbnail_cache.put(path, pixmap)
|
|
458
|
+
|
|
459
|
+
if path in self._path_to_item:
|
|
460
|
+
item = self._path_to_item[path]
|
|
461
|
+
self._set_item_icon(item, pixmap, path)
|
|
462
|
+
|
|
463
|
+
def _set_item_icon(self, item, pixmap, path):
|
|
464
|
+
"""Set icon with status border."""
|
|
465
|
+
status = self._statuses.get(path, AnnotationStatus.NO_LABELS)
|
|
466
|
+
bordered_pixmap = self._add_status_border(pixmap, status)
|
|
467
|
+
item.setIcon(QIcon(bordered_pixmap))
|
|
468
|
+
|
|
469
|
+
def _add_status_border(self, pixmap, status):
|
|
470
|
+
"""Add colored border to pixmap based on status."""
|
|
471
|
+
border_width = 4
|
|
472
|
+
new_size = self._icon_size + border_width * 2
|
|
473
|
+
|
|
474
|
+
bordered = QPixmap(new_size, new_size)
|
|
475
|
+
bordered.fill(self.STATUS_COLORS[status])
|
|
476
|
+
|
|
477
|
+
painter = QPainter(bordered)
|
|
478
|
+
# Center the original pixmap
|
|
479
|
+
x = border_width + (self._icon_size - pixmap.width()) // 2
|
|
480
|
+
y = border_width + (self._icon_size - pixmap.height()) // 2
|
|
481
|
+
painter.drawPixmap(x, y, pixmap)
|
|
482
|
+
painter.end()
|
|
483
|
+
|
|
484
|
+
return bordered
|
|
485
|
+
|
|
486
|
+
def _on_item_clicked(self, item):
|
|
487
|
+
"""Handle item click."""
|
|
488
|
+
path = item.data(Qt.UserRole)
|
|
489
|
+
if path:
|
|
490
|
+
self.image_selected.emit(path)
|
|
491
|
+
|
|
492
|
+
def _on_item_double_clicked(self, item):
|
|
493
|
+
"""Handle item double-click."""
|
|
494
|
+
path = item.data(Qt.UserRole)
|
|
495
|
+
if path:
|
|
496
|
+
self.image_activated.emit(path)
|
|
497
|
+
|
|
498
|
+
def select_image(self, image_path):
|
|
499
|
+
"""Select the specified image."""
|
|
500
|
+
if image_path in self._path_to_item:
|
|
501
|
+
item = self._path_to_item[image_path]
|
|
502
|
+
self.list_widget.setCurrentItem(item)
|
|
503
|
+
# Block scroll signals to prevent cascade during programmatic scroll
|
|
504
|
+
scrollbar = self.list_widget.verticalScrollBar()
|
|
505
|
+
scrollbar.blockSignals(True)
|
|
506
|
+
self.list_widget.scrollToItem(item)
|
|
507
|
+
scrollbar.blockSignals(False)
|
|
508
|
+
# Load visible thumbnails once after scrolling
|
|
509
|
+
self._load_visible_thumbnails()
|
|
510
|
+
|
|
511
|
+
def update_status(self, image_path, status):
|
|
512
|
+
"""Update annotation status for an image."""
|
|
513
|
+
self._statuses[image_path] = status
|
|
514
|
+
|
|
515
|
+
if image_path in self._path_to_item:
|
|
516
|
+
item = self._path_to_item[image_path]
|
|
517
|
+
# Reload icon with new border color
|
|
518
|
+
cached = self.thumbnail_cache.get(image_path)
|
|
519
|
+
if cached:
|
|
520
|
+
self._set_item_icon(item, cached, image_path)
|
|
521
|
+
|
|
522
|
+
def update_all_statuses(self, statuses):
|
|
523
|
+
"""Batch update annotation statuses."""
|
|
524
|
+
self._statuses.update(statuses)
|
|
525
|
+
for path, status in statuses.items():
|
|
526
|
+
if path in self._path_to_item:
|
|
527
|
+
item = self._path_to_item[path]
|
|
528
|
+
cached = self.thumbnail_cache.get(path)
|
|
529
|
+
if cached:
|
|
530
|
+
self._set_item_icon(item, cached, path)
|
|
531
|
+
|
|
532
|
+
def clear(self):
|
|
533
|
+
"""Clear all items."""
|
|
534
|
+
self.list_widget.clear()
|
|
535
|
+
self._path_to_item.clear()
|
|
536
|
+
self._image_list.clear()
|
|
537
|
+
self._loading_paths.clear()
|
|
538
|
+
self._statuses.clear()
|
|
539
|
+
|
|
540
|
+
def refresh_thumbnail(self, image_path):
|
|
541
|
+
"""Force reload of a specific thumbnail."""
|
|
542
|
+
self.thumbnail_cache.remove(image_path)
|
|
543
|
+
self._loading_paths.discard(image_path)
|
|
544
|
+
self._load_thumbnail_async(image_path)
|
|
545
|
+
|
|
546
|
+
def showEvent(self, event):
|
|
547
|
+
"""Load visible thumbnails when widget becomes visible."""
|
|
548
|
+
super().showEvent(event)
|
|
549
|
+
# Defer to prevent blocking during rapid show/hide
|
|
550
|
+
QTimer.singleShot(10, self._load_visible_thumbnails)
|
|
551
|
+
|
|
552
|
+
def resizeEvent(self, event):
|
|
553
|
+
"""Handle resize."""
|
|
554
|
+
super().resizeEvent(event)
|
|
555
|
+
# Defer to prevent blocking during resize cascade
|
|
556
|
+
QTimer.singleShot(10, self._load_visible_thumbnails)
|
|
557
|
+
|
|
558
|
+
def set_save_dir(self, save_dir):
|
|
559
|
+
"""Set the annotation save directory.
|
|
560
|
+
|
|
561
|
+
When changed, clears the cache to reload thumbnails with annotations.
|
|
562
|
+
"""
|
|
563
|
+
if self._save_dir != save_dir:
|
|
564
|
+
self._save_dir = save_dir
|
|
565
|
+
# Clear cache so thumbnails reload with annotations
|
|
566
|
+
self.thumbnail_cache.clear()
|
|
567
|
+
self._loading_paths.clear()
|
|
568
|
+
self._reload_all_thumbnails()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
import sys
|
|
4
|
+
try:
|
|
5
|
+
from PyQt5.QtGui import *
|
|
6
|
+
from PyQt5.QtCore import *
|
|
7
|
+
from PyQt5.QtWidgets import *
|
|
8
|
+
except ImportError:
|
|
9
|
+
# needed for py3+qt4
|
|
10
|
+
# Ref:
|
|
11
|
+
# http://pyqt.sourceforge.net/Docs/PyQt4/incompatible_apis.html
|
|
12
|
+
# http://stackoverflow.com/questions/21217399/pyqt4-qtcore-qvariant-object-instead-of-a-string
|
|
13
|
+
if sys.version_info.major >= 3:
|
|
14
|
+
import sip
|
|
15
|
+
sip.setapi('QVariant', 2)
|
|
16
|
+
from PyQt4.QtGui import *
|
|
17
|
+
from PyQt4.QtCore import *
|
|
18
|
+
|
|
19
|
+
# PyQt5: TypeError: unhashable type: 'QListWidgetItem'
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HashableQListWidgetItem(QListWidgetItem):
|
|
23
|
+
|
|
24
|
+
def __init__(self, *args):
|
|
25
|
+
super(HashableQListWidgetItem, self).__init__(*args)
|
|
26
|
+
|
|
27
|
+
def __hash__(self):
|
|
28
|
+
return hash(id(self))
|
libs/labelDialog.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
try:
|
|
2
|
+
from PyQt5.QtGui import *
|
|
3
|
+
from PyQt5.QtCore import *
|
|
4
|
+
from PyQt5.QtWidgets import *
|
|
5
|
+
except ImportError:
|
|
6
|
+
from PyQt4.QtGui import *
|
|
7
|
+
from PyQt4.QtCore import *
|
|
8
|
+
|
|
9
|
+
from libs.utils import new_icon, label_validator, trimmed
|
|
10
|
+
|
|
11
|
+
BB = QDialogButtonBox
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LabelDialog(QDialog):
|
|
15
|
+
|
|
16
|
+
def __init__(self, text="Enter object label", parent=None, list_item=None):
|
|
17
|
+
super(LabelDialog, self).__init__(parent)
|
|
18
|
+
|
|
19
|
+
self.edit = QLineEdit()
|
|
20
|
+
self.edit.setText(text)
|
|
21
|
+
self.edit.setValidator(label_validator())
|
|
22
|
+
self.edit.editingFinished.connect(self.post_process)
|
|
23
|
+
|
|
24
|
+
model = QStringListModel()
|
|
25
|
+
model.setStringList(list_item)
|
|
26
|
+
completer = QCompleter()
|
|
27
|
+
completer.setModel(model)
|
|
28
|
+
self.edit.setCompleter(completer)
|
|
29
|
+
|
|
30
|
+
self.button_box = bb = BB(BB.Ok | BB.Cancel, Qt.Horizontal, self)
|
|
31
|
+
bb.button(BB.Ok).setIcon(new_icon('done'))
|
|
32
|
+
bb.button(BB.Cancel).setIcon(new_icon('undo'))
|
|
33
|
+
bb.accepted.connect(self.validate)
|
|
34
|
+
bb.rejected.connect(self.reject)
|
|
35
|
+
|
|
36
|
+
layout = QVBoxLayout()
|
|
37
|
+
layout.addWidget(bb, alignment=Qt.AlignmentFlag.AlignLeft)
|
|
38
|
+
layout.addWidget(self.edit)
|
|
39
|
+
|
|
40
|
+
if list_item is not None and len(list_item) > 0:
|
|
41
|
+
self.list_widget = QListWidget(self)
|
|
42
|
+
for item in list_item:
|
|
43
|
+
self.list_widget.addItem(item)
|
|
44
|
+
self.list_widget.itemClicked.connect(self.list_item_click)
|
|
45
|
+
self.list_widget.itemDoubleClicked.connect(self.list_item_double_click)
|
|
46
|
+
layout.addWidget(self.list_widget)
|
|
47
|
+
|
|
48
|
+
self.setLayout(layout)
|
|
49
|
+
|
|
50
|
+
def validate(self):
|
|
51
|
+
if trimmed(self.edit.text()):
|
|
52
|
+
self.accept()
|
|
53
|
+
|
|
54
|
+
def post_process(self):
|
|
55
|
+
self.edit.setText(trimmed(self.edit.text()))
|
|
56
|
+
|
|
57
|
+
def pop_up(self, text='', move=True):
|
|
58
|
+
"""
|
|
59
|
+
Shows the dialog, setting the current text to `text`, and blocks the caller until the user has made a choice.
|
|
60
|
+
If the user entered a label, that label is returned, otherwise (i.e. if the user cancelled the action)
|
|
61
|
+
`None` is returned.
|
|
62
|
+
"""
|
|
63
|
+
self.edit.setText(text)
|
|
64
|
+
self.edit.setSelection(0, len(text))
|
|
65
|
+
self.edit.setFocus(Qt.PopupFocusReason)
|
|
66
|
+
if move:
|
|
67
|
+
cursor_pos = QCursor.pos()
|
|
68
|
+
|
|
69
|
+
# move OK button below cursor
|
|
70
|
+
btn = self.button_box.buttons()[0]
|
|
71
|
+
self.adjustSize()
|
|
72
|
+
btn.adjustSize()
|
|
73
|
+
offset = btn.mapToGlobal(btn.pos()) - self.pos()
|
|
74
|
+
offset += QPoint(btn.size().width() // 4, btn.size().height() // 2)
|
|
75
|
+
cursor_pos.setX(max(0, cursor_pos.x() - offset.x()))
|
|
76
|
+
cursor_pos.setY(max(0, cursor_pos.y() - offset.y()))
|
|
77
|
+
|
|
78
|
+
parent_bottom_right = self.parentWidget().geometry()
|
|
79
|
+
max_x = parent_bottom_right.x() + parent_bottom_right.width() - self.sizeHint().width()
|
|
80
|
+
max_y = parent_bottom_right.y() + parent_bottom_right.height() - self.sizeHint().height()
|
|
81
|
+
max_global = self.parentWidget().mapToGlobal(QPoint(max_x, max_y))
|
|
82
|
+
if cursor_pos.x() > max_global.x():
|
|
83
|
+
cursor_pos.setX(max_global.x())
|
|
84
|
+
if cursor_pos.y() > max_global.y():
|
|
85
|
+
cursor_pos.setY(max_global.y())
|
|
86
|
+
self.move(cursor_pos)
|
|
87
|
+
return trimmed(self.edit.text()) if self.exec_() else None
|
|
88
|
+
|
|
89
|
+
def list_item_click(self, t_qlist_widget_item):
|
|
90
|
+
text = trimmed(t_qlist_widget_item.text())
|
|
91
|
+
self.edit.setText(text)
|
|
92
|
+
|
|
93
|
+
def list_item_double_click(self, t_qlist_widget_item):
|
|
94
|
+
self.list_item_click(t_qlist_widget_item)
|
|
95
|
+
self.validate()
|