labelimgplusplus 2.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.
libs/galleryWidget.py ADDED
@@ -0,0 +1,656 @@
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, QPushButton, QFrame)
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
+ # Corner marker length (proportional to image size)
248
+ corner_len = max(4, min(img_w, img_h) // 8)
249
+
250
+ for label, bbox in annotations:
251
+ x_center, y_center, w, h = bbox
252
+
253
+ # Convert normalized coords to pixel coords
254
+ x1 = int((x_center - w / 2) * img_w)
255
+ y1 = int((y_center - h / 2) * img_h)
256
+ x2 = int((x_center + w / 2) * img_w)
257
+ y2 = int((y_center + h / 2) * img_h)
258
+
259
+ # Get color for this label
260
+ color = generate_color_by_text(label)
261
+ pen = QPen(color)
262
+ pen.setWidth(2)
263
+ painter.setPen(pen)
264
+
265
+ # Draw corner markers instead of full rectangle (less cluttered)
266
+ box_w = x2 - x1
267
+ box_h = y2 - y1
268
+ c = min(corner_len, box_w // 3, box_h // 3) # Adjust corner size for small boxes
269
+
270
+ if c >= 2:
271
+ # Top-left corner
272
+ painter.drawLine(x1, y1, x1 + c, y1)
273
+ painter.drawLine(x1, y1, x1, y1 + c)
274
+ # Top-right corner
275
+ painter.drawLine(x2, y1, x2 - c, y1)
276
+ painter.drawLine(x2, y1, x2, y1 + c)
277
+ # Bottom-left corner
278
+ painter.drawLine(x1, y2, x1 + c, y2)
279
+ painter.drawLine(x1, y2, x1, y2 - c)
280
+ # Bottom-right corner
281
+ painter.drawLine(x2, y2, x2 - c, y2)
282
+ painter.drawLine(x2, y2, x2, y2 - c)
283
+ else:
284
+ # Box too small, draw simple rectangle
285
+ painter.drawRect(x1, y1, box_w, box_h)
286
+
287
+ painter.end()
288
+ return image
289
+
290
+
291
+ class GalleryWidget(QWidget):
292
+ """Gallery widget using QListWidget in IconMode for tiled layout."""
293
+
294
+ image_selected = pyqtSignal(str) # Single click
295
+ image_activated = pyqtSignal(str) # Double click
296
+
297
+ DEFAULT_ICON_SIZE = 100
298
+ MIN_ICON_SIZE = 40
299
+ MAX_ICON_SIZE = 300
300
+
301
+ STATUS_COLORS = {
302
+ AnnotationStatus.NO_LABELS: QColor(150, 150, 150), # Gray
303
+ AnnotationStatus.HAS_LABELS: QColor(66, 133, 244), # Blue
304
+ AnnotationStatus.VERIFIED: QColor(52, 168, 83), # Green
305
+ }
306
+
307
+ def __init__(self, parent=None, show_size_slider=False):
308
+ super().__init__(parent)
309
+
310
+ self._icon_size = self.DEFAULT_ICON_SIZE
311
+ self._show_size_slider = show_size_slider
312
+ self._save_dir = None # Directory where annotations are saved
313
+
314
+ self.thumbnail_cache = ThumbnailCache(max_size=300)
315
+ self.thread_pool = QThreadPool.globalInstance()
316
+ self.thread_pool.setMaxThreadCount(4)
317
+
318
+ self._path_to_item = {}
319
+ self._image_list = []
320
+ self._loading_paths = set()
321
+ self._statuses = {}
322
+ self._loading_thumbnails = False # Guard against re-entrant calls
323
+
324
+ self._setup_ui()
325
+
326
+ def _setup_ui(self):
327
+ """Initialize UI components."""
328
+ self.list_widget = QListWidget(self)
329
+ self.list_widget.setViewMode(QListView.IconMode)
330
+ self._apply_icon_size()
331
+ self.list_widget.setResizeMode(QListView.Adjust)
332
+ self.list_widget.setWrapping(True)
333
+ self.list_widget.setSpacing(5)
334
+ self.list_widget.setMovement(QListView.Static)
335
+ self.list_widget.setUniformItemSizes(True)
336
+ self.list_widget.setWordWrap(True)
337
+
338
+ self.list_widget.itemClicked.connect(self._on_item_clicked)
339
+ self.list_widget.itemDoubleClicked.connect(self._on_item_double_clicked)
340
+ self.list_widget.verticalScrollBar().valueChanged.connect(self._on_scroll)
341
+
342
+ layout = QVBoxLayout(self)
343
+ layout.setContentsMargins(0, 0, 0, 0)
344
+
345
+ # Add size slider if enabled
346
+ if self._show_size_slider:
347
+ # Container frame for better visual grouping
348
+ slider_frame = QFrame()
349
+ slider_frame.setStyleSheet("""
350
+ QFrame {
351
+ background-color: #f5f5f5;
352
+ border-bottom: 1px solid #ddd;
353
+ }
354
+ """)
355
+ slider_layout = QHBoxLayout(slider_frame)
356
+ slider_layout.setContentsMargins(10, 8, 10, 8)
357
+ slider_layout.setSpacing(8)
358
+
359
+ # Preset size buttons
360
+ self.size_presets = {
361
+ 'S': 60,
362
+ 'M': 100,
363
+ 'L': 150,
364
+ 'XL': 220
365
+ }
366
+ for label, size in self.size_presets.items():
367
+ btn = QPushButton(label)
368
+ btn.setFixedSize(32, 26)
369
+ btn.setStyleSheet("""
370
+ QPushButton {
371
+ background-color: #fff;
372
+ border: 1px solid #ccc;
373
+ border-radius: 4px;
374
+ font-weight: bold;
375
+ font-size: 11px;
376
+ }
377
+ QPushButton:hover {
378
+ background-color: #e8e8e8;
379
+ border-color: #999;
380
+ }
381
+ QPushButton:pressed {
382
+ background-color: #ddd;
383
+ }
384
+ """)
385
+ btn.clicked.connect(lambda checked, s=size: self._set_preset_size(s))
386
+ slider_layout.addWidget(btn)
387
+
388
+ slider_layout.addSpacing(10)
389
+
390
+ # Size slider
391
+ self.size_slider = QSlider(Qt.Horizontal)
392
+ self.size_slider.setMinimum(self.MIN_ICON_SIZE)
393
+ self.size_slider.setMaximum(self.MAX_ICON_SIZE)
394
+ self.size_slider.setValue(self._icon_size)
395
+ self.size_slider.setStyleSheet("""
396
+ QSlider::groove:horizontal {
397
+ height: 6px;
398
+ background: #ddd;
399
+ border-radius: 3px;
400
+ }
401
+ QSlider::handle:horizontal {
402
+ background: #4285f4;
403
+ width: 16px;
404
+ height: 16px;
405
+ margin: -5px 0;
406
+ border-radius: 8px;
407
+ }
408
+ QSlider::handle:horizontal:hover {
409
+ background: #3367d6;
410
+ }
411
+ QSlider::sub-page:horizontal {
412
+ background: #4285f4;
413
+ border-radius: 3px;
414
+ }
415
+ """)
416
+ self.size_slider.valueChanged.connect(self._on_size_changed)
417
+ slider_layout.addWidget(self.size_slider, 1)
418
+
419
+ # Size value display
420
+ self.size_value_label = QLabel(f"{self._icon_size}px")
421
+ self.size_value_label.setMinimumWidth(50)
422
+ self.size_value_label.setStyleSheet("font-weight: bold; color: #333;")
423
+ slider_layout.addWidget(self.size_value_label)
424
+
425
+ layout.addWidget(slider_frame)
426
+
427
+ layout.addWidget(self.list_widget)
428
+
429
+ def _apply_icon_size(self):
430
+ """Apply current icon size to list widget."""
431
+ grid_size = self._icon_size + 20
432
+ self.list_widget.setIconSize(QSize(self._icon_size, self._icon_size))
433
+ self.list_widget.setGridSize(QSize(grid_size, grid_size + 20))
434
+
435
+ def _on_size_changed(self, value):
436
+ """Handle size slider change."""
437
+ self._icon_size = value
438
+ if hasattr(self, 'size_value_label'):
439
+ self.size_value_label.setText(f"{value}px")
440
+ self._apply_icon_size()
441
+ # Clear cache and reload thumbnails at new size
442
+ self.thumbnail_cache.clear()
443
+ self._loading_paths.clear()
444
+ self._reload_all_thumbnails()
445
+
446
+ def _set_preset_size(self, size):
447
+ """Set thumbnail size from preset button."""
448
+ if hasattr(self, 'size_slider'):
449
+ self.size_slider.setValue(size)
450
+ else:
451
+ self._on_size_changed(size)
452
+
453
+ def _reload_all_thumbnails(self):
454
+ """Reload all thumbnails at current size."""
455
+ for path, item in self._path_to_item.items():
456
+ # Set placeholder
457
+ placeholder = QPixmap(self._icon_size, self._icon_size)
458
+ placeholder.fill(QColor(220, 220, 220))
459
+ item.setIcon(QIcon(placeholder))
460
+ item.setSizeHint(QSize(self._icon_size + 20, self._icon_size + 40))
461
+ self._load_visible_thumbnails()
462
+
463
+ def set_image_list(self, image_paths):
464
+ """Populate gallery with images."""
465
+ self.clear()
466
+ self._image_list = list(image_paths)
467
+
468
+ for path in image_paths:
469
+ self._add_item(path)
470
+
471
+ # Defer thumbnail loading to next event loop cycle to prevent blocking
472
+ QTimer.singleShot(0, self._load_visible_thumbnails)
473
+
474
+ def _add_item(self, image_path):
475
+ """Add an item to the list widget."""
476
+ filename = os.path.basename(image_path)
477
+ if len(filename) > 12:
478
+ display_name = filename[:10] + "..."
479
+ else:
480
+ display_name = filename
481
+
482
+ item = QListWidgetItem(display_name)
483
+ item.setToolTip(filename)
484
+ grid_size = self._icon_size + 20
485
+ item.setSizeHint(QSize(grid_size, grid_size + 20))
486
+
487
+ # Set placeholder icon
488
+ placeholder = QPixmap(self._icon_size, self._icon_size)
489
+ placeholder.fill(QColor(220, 220, 220))
490
+ item.setIcon(QIcon(placeholder))
491
+
492
+ # Set initial status color (gray background)
493
+ item.setBackground(QBrush(QColor(240, 240, 240)))
494
+
495
+ # Store path in item's data
496
+ item.setData(Qt.UserRole, image_path)
497
+
498
+ self.list_widget.addItem(item)
499
+ self._path_to_item[image_path] = item
500
+
501
+ def _on_scroll(self):
502
+ """Handle scroll to load visible thumbnails."""
503
+ self._load_visible_thumbnails()
504
+
505
+ def _load_visible_thumbnails(self):
506
+ """Load thumbnails for visible items."""
507
+ # Guard against re-entrant calls during layout/scroll cascades
508
+ if self._loading_thumbnails:
509
+ return
510
+ self._loading_thumbnails = True
511
+ try:
512
+ viewport_rect = self.list_widget.viewport().rect()
513
+ count = self.list_widget.count()
514
+
515
+ for i in range(count):
516
+ item = self.list_widget.item(i)
517
+ item_rect = self.list_widget.visualItemRect(item)
518
+
519
+ # Check if item is visible (with some buffer)
520
+ if item_rect.intersects(viewport_rect.adjusted(0, -200, 0, 200)):
521
+ path = item.data(Qt.UserRole)
522
+ if path and path not in self._loading_paths:
523
+ cached = self.thumbnail_cache.get(path)
524
+ if cached:
525
+ self._set_item_icon(item, cached, path)
526
+ else:
527
+ self._load_thumbnail_async(path)
528
+ finally:
529
+ self._loading_thumbnails = False
530
+
531
+ def _load_thumbnail_async(self, image_path):
532
+ """Load thumbnail in background thread."""
533
+ if image_path in self._loading_paths:
534
+ return
535
+
536
+ self._loading_paths.add(image_path)
537
+ worker = ThumbnailLoaderWorker(image_path, self._icon_size, self._save_dir)
538
+ worker.signals.thumbnail_ready.connect(self._on_thumbnail_loaded)
539
+ self.thread_pool.start(worker)
540
+
541
+ def _on_thumbnail_loaded(self, path, image):
542
+ """Handle loaded thumbnail."""
543
+ self._loading_paths.discard(path)
544
+ pixmap = QPixmap.fromImage(image)
545
+ self.thumbnail_cache.put(path, pixmap)
546
+
547
+ if path in self._path_to_item:
548
+ item = self._path_to_item[path]
549
+ self._set_item_icon(item, pixmap, path)
550
+
551
+ def _set_item_icon(self, item, pixmap, path):
552
+ """Set icon with status border."""
553
+ status = self._statuses.get(path, AnnotationStatus.NO_LABELS)
554
+ bordered_pixmap = self._add_status_border(pixmap, status)
555
+ item.setIcon(QIcon(bordered_pixmap))
556
+
557
+ def _add_status_border(self, pixmap, status):
558
+ """Add colored border to pixmap based on status."""
559
+ border_width = 4
560
+ new_size = self._icon_size + border_width * 2
561
+
562
+ bordered = QPixmap(new_size, new_size)
563
+ bordered.fill(self.STATUS_COLORS[status])
564
+
565
+ painter = QPainter(bordered)
566
+ # Center the original pixmap
567
+ x = border_width + (self._icon_size - pixmap.width()) // 2
568
+ y = border_width + (self._icon_size - pixmap.height()) // 2
569
+ painter.drawPixmap(x, y, pixmap)
570
+ painter.end()
571
+
572
+ return bordered
573
+
574
+ def _on_item_clicked(self, item):
575
+ """Handle item click."""
576
+ path = item.data(Qt.UserRole)
577
+ if path:
578
+ self.image_selected.emit(path)
579
+
580
+ def _on_item_double_clicked(self, item):
581
+ """Handle item double-click."""
582
+ path = item.data(Qt.UserRole)
583
+ if path:
584
+ self.image_activated.emit(path)
585
+
586
+ def select_image(self, image_path):
587
+ """Select the specified image."""
588
+ if image_path in self._path_to_item:
589
+ item = self._path_to_item[image_path]
590
+ self.list_widget.setCurrentItem(item)
591
+ # Block scroll signals to prevent cascade during programmatic scroll
592
+ scrollbar = self.list_widget.verticalScrollBar()
593
+ scrollbar.blockSignals(True)
594
+ self.list_widget.scrollToItem(item)
595
+ scrollbar.blockSignals(False)
596
+ # Load visible thumbnails once after scrolling
597
+ self._load_visible_thumbnails()
598
+
599
+ def update_status(self, image_path, status):
600
+ """Update annotation status for an image."""
601
+ self._statuses[image_path] = status
602
+
603
+ if image_path in self._path_to_item:
604
+ item = self._path_to_item[image_path]
605
+ # Reload icon with new border color
606
+ cached = self.thumbnail_cache.get(image_path)
607
+ if cached:
608
+ self._set_item_icon(item, cached, image_path)
609
+
610
+ def update_all_statuses(self, statuses):
611
+ """Batch update annotation statuses."""
612
+ self._statuses.update(statuses)
613
+ for path, status in statuses.items():
614
+ if path in self._path_to_item:
615
+ item = self._path_to_item[path]
616
+ cached = self.thumbnail_cache.get(path)
617
+ if cached:
618
+ self._set_item_icon(item, cached, path)
619
+
620
+ def clear(self):
621
+ """Clear all items."""
622
+ self.list_widget.clear()
623
+ self._path_to_item.clear()
624
+ self._image_list.clear()
625
+ self._loading_paths.clear()
626
+ self._statuses.clear()
627
+
628
+ def refresh_thumbnail(self, image_path):
629
+ """Force reload of a specific thumbnail."""
630
+ self.thumbnail_cache.remove(image_path)
631
+ self._loading_paths.discard(image_path)
632
+ self._load_thumbnail_async(image_path)
633
+
634
+ def showEvent(self, event):
635
+ """Load visible thumbnails when widget becomes visible."""
636
+ super().showEvent(event)
637
+ # Defer to prevent blocking during rapid show/hide
638
+ QTimer.singleShot(10, self._load_visible_thumbnails)
639
+
640
+ def resizeEvent(self, event):
641
+ """Handle resize."""
642
+ super().resizeEvent(event)
643
+ # Defer to prevent blocking during resize cascade
644
+ QTimer.singleShot(10, self._load_visible_thumbnails)
645
+
646
+ def set_save_dir(self, save_dir):
647
+ """Set the annotation save directory.
648
+
649
+ When changed, clears the cache to reload thumbnails with annotations.
650
+ """
651
+ if self._save_dir != save_dir:
652
+ self._save_dir = save_dir
653
+ # Clear cache so thumbnails reload with annotations
654
+ self.thumbnail_cache.clear()
655
+ self._loading_paths.clear()
656
+ 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))