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.
labelImg.py ADDED
@@ -0,0 +1,2486 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ import argparse
4
+ import codecs
5
+ import os.path
6
+ import platform
7
+ import shutil
8
+ import sys
9
+ import webbrowser as wb
10
+ from functools import partial
11
+
12
+ try:
13
+ from PyQt5.QtGui import *
14
+ from PyQt5.QtCore import *
15
+ from PyQt5.QtWidgets import *
16
+ except ImportError:
17
+ # needed for py3+qt4
18
+ # Ref:
19
+ # http://pyqt.sourceforge.net/Docs/PyQt4/incompatible_apis.html
20
+ # http://stackoverflow.com/questions/21217399/pyqt4-qtcore-qvariant-object-instead-of-a-string
21
+ if sys.version_info.major >= 3:
22
+ import sip
23
+ sip.setapi('QVariant', 2)
24
+ from PyQt4.QtGui import *
25
+ from PyQt4.QtCore import *
26
+
27
+ from libs.combobox import ComboBox
28
+ from libs.default_label_combobox import DefaultLabelComboBox
29
+ from libs.resources import *
30
+ from libs.constants import *
31
+ from libs.utils import *
32
+ from libs.settings import Settings
33
+ from libs.shape import Shape, DEFAULT_LINE_COLOR, DEFAULT_FILL_COLOR
34
+ from libs.stringBundle import StringBundle
35
+ from libs.canvas import Canvas
36
+ from libs.zoomWidget import ZoomWidget
37
+ from libs.lightWidget import LightWidget
38
+ from libs.labelDialog import LabelDialog
39
+ from libs.colorDialog import ColorDialog
40
+ from libs.labelFile import LabelFile, LabelFileError, LabelFileFormat
41
+ from libs.toolBar import ToolBar, DropdownToolButton
42
+ from libs.styles import TOOLBAR_STYLE, get_combined_style
43
+ from libs.pascal_voc_io import PascalVocReader
44
+ from libs.pascal_voc_io import XML_EXT
45
+ from libs.yolo_io import YoloReader
46
+ from libs.yolo_io import TXT_EXT
47
+ from libs.create_ml_io import CreateMLReader
48
+ from libs.create_ml_io import JSON_EXT
49
+ from libs.ustr import ustr
50
+ from libs.hashableQListWidgetItem import HashableQListWidgetItem
51
+ from libs.galleryWidget import GalleryWidget, AnnotationStatus
52
+ from libs.commands import UndoStack, CreateShapeCommand, DeleteShapeCommand, MoveShapeCommand, EditLabelCommand
53
+
54
+ __appname__ = 'labelImg'
55
+
56
+
57
+ class WindowMixin(object):
58
+
59
+ def menu(self, title, actions=None):
60
+ menu = self.menuBar().addMenu(title)
61
+ if actions:
62
+ add_actions(menu, actions)
63
+ return menu
64
+
65
+ def toolbar(self, title, actions=None):
66
+ toolbar = ToolBar(title)
67
+ toolbar.setObjectName(u'%sToolBar' % title)
68
+ # toolbar.setOrientation(Qt.Vertical)
69
+ toolbar.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
70
+ if actions:
71
+ add_actions(toolbar, actions)
72
+ self.addToolBar(Qt.LeftToolBarArea, toolbar)
73
+ return toolbar
74
+
75
+
76
+ class MainWindow(QMainWindow, WindowMixin):
77
+ FIT_WINDOW, FIT_WIDTH, MANUAL_ZOOM = list(range(3))
78
+
79
+ def __init__(self, default_filename=None, default_prefdef_class_file=None, default_save_dir=None):
80
+ super(MainWindow, self).__init__()
81
+ self.setWindowTitle(__appname__)
82
+
83
+ # Load setting in the main thread
84
+ self.settings = Settings()
85
+ self.settings.load()
86
+ settings = self.settings
87
+
88
+ self.os_name = platform.system()
89
+
90
+ # Load string bundle for i18n
91
+ self.string_bundle = StringBundle.get_bundle()
92
+ get_str = lambda str_id: self.string_bundle.get_string(str_id)
93
+
94
+ # Save as Pascal voc xml
95
+ self.default_save_dir = default_save_dir
96
+ self.label_file_format = settings.get(SETTING_LABEL_FILE_FORMAT, LabelFileFormat.PASCAL_VOC)
97
+
98
+ # For loading all image under a directory
99
+ self.m_img_list = []
100
+ self._path_to_idx = {} # O(1) lookup: path -> index
101
+ self._annotation_status_cache = {} # Cache: path -> status (reduces I/O)
102
+
103
+ # Memory optimization for large images (Issue #31)
104
+ self._image_scale_factor = 1.0 # Display size / Original size
105
+ self._original_image_size = None # QSize of original image
106
+
107
+ self.dir_name = None
108
+ self.label_hist = []
109
+ self.last_open_dir = None
110
+ self.cur_img_idx = 0
111
+ self.img_count = len(self.m_img_list)
112
+
113
+ # Whether we need to save or not.
114
+ self.dirty = False
115
+
116
+ # Clipboard for copy/paste annotations across images
117
+ self.clipboard_shapes = []
118
+
119
+ self._no_selection_slot = False
120
+ self._beginner = True
121
+ self.gallery_mode_enabled = False
122
+ self._normal_central_widget = None
123
+ self.screencast = "https://youtu.be/p0nR2YsCY_U"
124
+
125
+ # Load predefined classes to the list
126
+ self.load_predefined_classes(default_prefdef_class_file)
127
+
128
+ if self.label_hist:
129
+ self.default_label = self.label_hist[0]
130
+ else:
131
+ print("Not find:/data/predefined_classes.txt (optional)")
132
+
133
+ # Main widgets and related state.
134
+ self.label_dialog = LabelDialog(parent=self, list_item=self.label_hist)
135
+
136
+ self.items_to_shapes = {}
137
+ self.shapes_to_items = {}
138
+ self.prev_label_text = ''
139
+
140
+ list_layout = QVBoxLayout()
141
+ list_layout.setContentsMargins(0, 0, 0, 0)
142
+
143
+ # Create a widget for using default label
144
+ self.use_default_label_checkbox = QCheckBox(get_str('useDefaultLabel'))
145
+ self.use_default_label_checkbox.setChecked(False)
146
+ self.default_label_combo_box = DefaultLabelComboBox(self,items=self.label_hist)
147
+
148
+ use_default_label_qhbox_layout = QHBoxLayout()
149
+ use_default_label_qhbox_layout.addWidget(self.use_default_label_checkbox)
150
+ use_default_label_qhbox_layout.addWidget(self.default_label_combo_box)
151
+ use_default_label_container = QWidget()
152
+ use_default_label_container.setLayout(use_default_label_qhbox_layout)
153
+
154
+ # Create a widget for edit and diffc button
155
+ self.diffc_button = QCheckBox(get_str('useDifficult'))
156
+ self.diffc_button.setChecked(False)
157
+ self.diffc_button.stateChanged.connect(self.button_state)
158
+ self.edit_button = QToolButton()
159
+ self.edit_button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
160
+
161
+ # Add some of widgets to list_layout
162
+ list_layout.addWidget(self.edit_button)
163
+ list_layout.addWidget(self.diffc_button)
164
+ list_layout.addWidget(use_default_label_container)
165
+
166
+ # Create and add combobox for showing unique labels in group
167
+ self.combo_box = ComboBox(self)
168
+ list_layout.addWidget(self.combo_box)
169
+
170
+ # Create and add a widget for showing current label items
171
+ self.label_list = QListWidget()
172
+ label_list_container = QWidget()
173
+ label_list_container.setLayout(list_layout)
174
+ self.label_list.itemActivated.connect(self.label_selection_changed)
175
+ self.label_list.itemSelectionChanged.connect(self.label_selection_changed)
176
+ self.label_list.itemDoubleClicked.connect(self.edit_label)
177
+ # Connect to itemChanged to detect checkbox changes.
178
+ self.label_list.itemChanged.connect(self.label_item_changed)
179
+ list_layout.addWidget(self.label_list)
180
+
181
+
182
+
183
+ self.dock = QDockWidget(get_str('boxLabelText'), self)
184
+ self.dock.setObjectName(get_str('labels'))
185
+ self.dock.setWidget(label_list_container)
186
+
187
+ # File list widget (existing list view)
188
+ self.file_list_widget = QListWidget()
189
+ self.file_list_widget.itemDoubleClicked.connect(self.file_item_double_clicked)
190
+ self.file_list_widget.itemClicked.connect(self.file_item_clicked)
191
+
192
+ # Gallery widget (new thumbnail view)
193
+ self.gallery_widget = GalleryWidget()
194
+ self.gallery_widget.image_selected.connect(self.gallery_image_selected)
195
+ self.gallery_widget.image_activated.connect(self.gallery_image_activated)
196
+
197
+ # Tab widget to hold both views
198
+ self.file_view_tabs = QTabWidget()
199
+ self.file_view_tabs.addTab(self.file_list_widget, get_str('listView'))
200
+ self.file_view_tabs.addTab(self.gallery_widget, get_str('galleryView'))
201
+ self.file_view_tabs.currentChanged.connect(self.on_file_view_tab_changed)
202
+
203
+ file_list_layout = QVBoxLayout()
204
+ file_list_layout.setContentsMargins(0, 0, 0, 0)
205
+ file_list_layout.addWidget(self.file_view_tabs)
206
+ file_list_container = QWidget()
207
+ file_list_container.setLayout(file_list_layout)
208
+ self.file_dock = QDockWidget(get_str('fileList'), self)
209
+ self.file_dock.setObjectName(get_str('files'))
210
+ self.file_dock.setWidget(file_list_container)
211
+
212
+ self.zoom_widget = ZoomWidget()
213
+ self.light_widget = LightWidget(get_str('lightWidgetTitle'))
214
+ self.color_dialog = ColorDialog(parent=self)
215
+
216
+ self.canvas = Canvas(parent=self)
217
+ self.canvas.zoomRequest.connect(self.zoom_request)
218
+ self.canvas.lightRequest.connect(self.light_request)
219
+ self.canvas.set_drawing_shape_to_square(settings.get(SETTING_DRAW_SQUARE, False))
220
+
221
+ scroll = QScrollArea()
222
+ scroll.setWidget(self.canvas)
223
+ scroll.setWidgetResizable(True)
224
+ self.scroll_bars = {
225
+ Qt.Vertical: scroll.verticalScrollBar(),
226
+ Qt.Horizontal: scroll.horizontalScrollBar()
227
+ }
228
+ self.scroll_area = scroll
229
+ self.canvas.scrollRequest.connect(self.scroll_request)
230
+
231
+ self.canvas.newShape.connect(self.new_shape)
232
+ self.canvas.shapeMoved.connect(self.set_dirty)
233
+ self.canvas.selectionChanged.connect(self.shape_selection_changed)
234
+ self.canvas.drawingPolygon.connect(self.toggle_drawing_sensitive)
235
+
236
+ # Initialize undo/redo system
237
+ self.undo_stack = UndoStack(max_size=50)
238
+ self.undo_stack.add_callback(self.update_undo_redo_actions)
239
+
240
+ self.setCentralWidget(scroll)
241
+ self.addDockWidget(Qt.RightDockWidgetArea, self.dock)
242
+ self.addDockWidget(Qt.RightDockWidgetArea, self.file_dock)
243
+ self.file_dock.setFeatures(QDockWidget.DockWidgetFloatable)
244
+
245
+ self.dock_features = QDockWidget.DockWidgetClosable | QDockWidget.DockWidgetFloatable
246
+ self.dock.setFeatures(self.dock.features() ^ self.dock_features)
247
+
248
+ # Actions
249
+ action = partial(new_action, self)
250
+ quit = action(get_str('quit'), self.close,
251
+ 'Ctrl+Q', 'quit', get_str('quitApp'))
252
+
253
+ open = action(get_str('openFile'), self.open_file,
254
+ 'Ctrl+O', 'open', get_str('openFileDetail'))
255
+
256
+ open_dir = action(get_str('openDir'), self.open_dir_dialog,
257
+ 'Ctrl+u', 'open', get_str('openDir'))
258
+
259
+ change_save_dir = action(get_str('changeSaveDir'), self.change_save_dir_dialog,
260
+ 'Ctrl+r', 'open', get_str('changeSavedAnnotationDir'))
261
+
262
+ open_annotation = action(get_str('openAnnotation'), self.open_annotation_dialog,
263
+ 'Ctrl+Shift+O', 'open', get_str('openAnnotationDetail'))
264
+ copy_prev_bounding = action(get_str('copyPrevBounding'), self.copy_previous_bounding_boxes, 'Ctrl+Shift+V', 'copy', get_str('copyPrevBounding'))
265
+
266
+ open_next_image = action(get_str('nextImg'), self.open_next_image,
267
+ 'd', 'next', get_str('nextImgDetail'))
268
+
269
+ open_prev_image = action(get_str('prevImg'), self.open_prev_image,
270
+ 'a', 'prev', get_str('prevImgDetail'))
271
+
272
+ verify = action(get_str('verifyImg'), self.verify_image,
273
+ 'space', 'verify', get_str('verifyImgDetail'))
274
+
275
+ save = action(get_str('save'), self.save_file,
276
+ 'Ctrl+S', 'save', get_str('saveDetail'), enabled=False)
277
+
278
+ def get_format_meta(format):
279
+ """
280
+ returns a tuple containing (title, icon_name) of the selected format
281
+ """
282
+ if format == LabelFileFormat.PASCAL_VOC:
283
+ return '&PascalVOC', 'format_voc'
284
+ elif format == LabelFileFormat.YOLO:
285
+ return '&YOLO', 'format_yolo'
286
+ elif format == LabelFileFormat.CREATE_ML:
287
+ return '&CreateML', 'format_createml'
288
+
289
+ save_format = action(get_format_meta(self.label_file_format)[0],
290
+ self.change_format, 'Ctrl+Y',
291
+ get_format_meta(self.label_file_format)[1],
292
+ get_str('changeSaveFormat'), enabled=True)
293
+
294
+ save_as = action(get_str('saveAs'), self.save_file_as,
295
+ 'Ctrl+Shift+S', 'save-as', get_str('saveAsDetail'), enabled=False)
296
+
297
+ close = action(get_str('closeCur'), self.close_file, 'Ctrl+W', 'close', get_str('closeCurDetail'))
298
+
299
+ delete_image = action(get_str('deleteImg'), self.delete_image, 'Ctrl+Shift+D', 'close', get_str('deleteImgDetail'))
300
+
301
+ reset_all = action(get_str('resetAll'), self.reset_all, None, 'resetall', get_str('resetAllDetail'))
302
+
303
+ color1 = action(get_str('boxLineColor'), self.choose_color1,
304
+ 'Ctrl+L', 'color_line', get_str('boxLineColorDetail'))
305
+
306
+ create_mode = action(get_str('crtBox'), self.set_create_mode,
307
+ 'w', 'new', get_str('crtBoxDetail'), enabled=False)
308
+ edit_mode = action(get_str('editBox'), self.set_edit_mode,
309
+ 'Ctrl+J', 'edit', get_str('editBoxDetail'), enabled=False)
310
+
311
+ create = action(get_str('crtBox'), self.create_shape,
312
+ 'w', 'new', get_str('crtBoxDetail'), enabled=False)
313
+ delete = action(get_str('delBox'), self.delete_selected_shape,
314
+ 'Delete', 'delete', get_str('delBoxDetail'), enabled=False)
315
+ copy = action(get_str('dupBox'), self.copy_selected_shape,
316
+ 'Ctrl+D', 'copy', get_str('dupBoxDetail'),
317
+ enabled=False)
318
+
319
+ copy_to_clipboard = action(get_str('copyBox'), self.copy_to_clipboard,
320
+ 'Ctrl+C', 'copy', get_str('copyBoxDetail'),
321
+ enabled=False)
322
+ paste_from_clipboard = action(get_str('pasteBox'), self.paste_from_clipboard,
323
+ 'Ctrl+V', 'paste', get_str('pasteBoxDetail'),
324
+ enabled=False)
325
+ copy_all_to_clipboard = action(get_str('copyAllBoxes'), self.copy_all_to_clipboard,
326
+ 'Ctrl+Shift+C', 'copy', get_str('copyAllBoxesDetail'),
327
+ enabled=False)
328
+
329
+ undo = action(get_str('undo'), self.undo_action,
330
+ 'Ctrl+Z', 'undo', get_str('undoDetail'), enabled=False)
331
+ redo = action(get_str('redo'), self.redo_action,
332
+ 'Ctrl+Shift+Z', 'redo', get_str('redoDetail'), enabled=False)
333
+
334
+ advanced_mode = action(get_str('advancedMode'), self.toggle_advanced_mode,
335
+ 'Ctrl+Shift+A', 'expert', get_str('advancedModeDetail'),
336
+ checkable=True)
337
+
338
+ gallery_mode = action(get_str('galleryMode'), self.toggle_gallery_mode,
339
+ 'Ctrl+G', 'labels', get_str('galleryModeDetail'),
340
+ checkable=True)
341
+
342
+ hide_all = action(get_str('hideAllBox'), partial(self.toggle_polygons, False),
343
+ 'Ctrl+H', 'hide', get_str('hideAllBoxDetail'),
344
+ enabled=False)
345
+ show_all = action(get_str('showAllBox'), partial(self.toggle_polygons, True),
346
+ 'Ctrl+A', 'hide', get_str('showAllBoxDetail'),
347
+ enabled=False)
348
+
349
+ help_default = action(get_str('tutorialDefault'), self.show_default_tutorial_dialog, None, 'help', get_str('tutorialDetail'))
350
+ show_info = action(get_str('info'), self.show_info_dialog, None, 'help', get_str('info'))
351
+ show_shortcut = action(get_str('shortcut'), self.show_shortcuts_dialog, None, 'help', get_str('shortcut'))
352
+
353
+ zoom = QWidgetAction(self)
354
+ zoom.setDefaultWidget(self.zoom_widget)
355
+ self.zoom_widget.setWhatsThis(
356
+ u"Zoom in or out of the image. Also accessible with"
357
+ " %s and %s from the canvas." % (format_shortcut("Ctrl+[-+]"),
358
+ format_shortcut("Ctrl+Wheel")))
359
+ self.zoom_widget.setEnabled(False)
360
+
361
+ zoom_in = action(get_str('zoomin'), partial(self.add_zoom, 10),
362
+ 'Ctrl++', 'zoom-in', get_str('zoominDetail'), enabled=False)
363
+ zoom_out = action(get_str('zoomout'), partial(self.add_zoom, -10),
364
+ 'Ctrl+-', 'zoom-out', get_str('zoomoutDetail'), enabled=False)
365
+ zoom_org = action(get_str('originalsize'), partial(self.set_zoom, 100),
366
+ 'Ctrl+=', 'zoom', get_str('originalsizeDetail'), enabled=False)
367
+ fit_window = action(get_str('fitWin'), self.set_fit_window,
368
+ 'Ctrl+F', 'fit-window', get_str('fitWinDetail'),
369
+ checkable=True, enabled=False)
370
+ fit_width = action(get_str('fitWidth'), self.set_fit_width,
371
+ 'Ctrl+Shift+F', 'fit-width', get_str('fitWidthDetail'),
372
+ checkable=True, enabled=False)
373
+ # Group zoom controls into a list for easier toggling.
374
+ zoom_actions = (self.zoom_widget, zoom_in, zoom_out,
375
+ zoom_org, fit_window, fit_width)
376
+ self.zoom_mode = self.MANUAL_ZOOM
377
+ self.scalers = {
378
+ self.FIT_WINDOW: self.scale_fit_window,
379
+ self.FIT_WIDTH: self.scale_fit_width,
380
+ # Set to one to scale to 100% when loading files.
381
+ self.MANUAL_ZOOM: lambda: 1,
382
+ }
383
+
384
+ light = QWidgetAction(self)
385
+ light.setDefaultWidget(self.light_widget)
386
+ self.light_widget.setWhatsThis(
387
+ u"Brighten or darken current image. Also accessible with"
388
+ " %s and %s from the canvas." % (format_shortcut("Ctrl+Shift+[-+]"),
389
+ format_shortcut("Ctrl+Shift+Wheel")))
390
+ self.light_widget.setEnabled(False)
391
+
392
+ light_brighten = action(get_str('lightbrighten'), partial(self.add_light, 10),
393
+ 'Ctrl+Shift++', 'light_lighten', get_str('lightbrightenDetail'), enabled=False)
394
+ light_darken = action(get_str('lightdarken'), partial(self.add_light, -10),
395
+ 'Ctrl+Shift+-', 'light_darken', get_str('lightdarkenDetail'), enabled=False)
396
+ light_org = action(get_str('lightreset'), partial(self.set_light, 50),
397
+ 'Ctrl+Shift+=', 'light_reset', get_str('lightresetDetail'), checkable=True, enabled=False)
398
+ light_org.setChecked(True)
399
+
400
+ # Create brightness dropdown button for toolbar
401
+ brightness_dropdown = DropdownToolButton(
402
+ "Brightness",
403
+ new_icon('sun'),
404
+ [light_brighten, light_darken, None, light_org]
405
+ )
406
+
407
+ # Group light controls into a list for easier toggling.
408
+ light_actions = (self.light_widget, light_brighten,
409
+ light_darken, light_org, brightness_dropdown)
410
+
411
+ edit = action(get_str('editLabel'), self.edit_label,
412
+ 'Ctrl+E', 'edit', get_str('editLabelDetail'),
413
+ enabled=False)
414
+ self.edit_button.setDefaultAction(edit)
415
+
416
+ shape_line_color = action(get_str('shapeLineColor'), self.choose_shape_line_color,
417
+ icon='color_line', tip=get_str('shapeLineColorDetail'),
418
+ enabled=False)
419
+ shape_fill_color = action(get_str('shapeFillColor'), self.choose_shape_fill_color,
420
+ icon='color', tip=get_str('shapeFillColorDetail'),
421
+ enabled=False)
422
+
423
+ labels = self.dock.toggleViewAction()
424
+ labels.setText(get_str('showHide'))
425
+ labels.setShortcut('Ctrl+Shift+L')
426
+
427
+ # Label list context menu.
428
+ label_menu = QMenu()
429
+ add_actions(label_menu, (edit, delete))
430
+ self.label_list.setContextMenuPolicy(Qt.CustomContextMenu)
431
+ self.label_list.customContextMenuRequested.connect(
432
+ self.pop_label_list_menu)
433
+
434
+ # Draw squares/rectangles
435
+ self.draw_squares_option = QAction(get_str('drawSquares'), self)
436
+ self.draw_squares_option.setShortcut('Ctrl+Shift+R')
437
+ self.draw_squares_option.setCheckable(True)
438
+ self.draw_squares_option.setChecked(settings.get(SETTING_DRAW_SQUARE, False))
439
+ self.draw_squares_option.triggered.connect(self.toggle_draw_square)
440
+
441
+ # Store actions for further handling.
442
+ self.actions = Struct(save=save, save_format=save_format, saveAs=save_as, open=open, close=close, resetAll=reset_all, deleteImg=delete_image,
443
+ lineColor=color1, create=create, delete=delete, edit=edit, copy=copy,
444
+ copyToClipboard=copy_to_clipboard, pasteFromClipboard=paste_from_clipboard,
445
+ copyAllToClipboard=copy_all_to_clipboard,
446
+ undo=undo, redo=redo,
447
+ createMode=create_mode, editMode=edit_mode, advancedMode=advanced_mode, galleryMode=gallery_mode,
448
+ shapeLineColor=shape_line_color, shapeFillColor=shape_fill_color,
449
+ zoom=zoom, zoomIn=zoom_in, zoomOut=zoom_out, zoomOrg=zoom_org,
450
+ fitWindow=fit_window, fitWidth=fit_width,
451
+ zoomActions=zoom_actions,
452
+ lightBrighten=light_brighten, lightDarken=light_darken, lightOrg=light_org,
453
+ lightActions=light_actions,
454
+ fileMenuActions=(
455
+ open, open_dir, save, save_as, close, reset_all, quit),
456
+ beginner=(), advanced=(),
457
+ editMenu=(undo, redo, None, edit, copy, copy_to_clipboard,
458
+ paste_from_clipboard, copy_all_to_clipboard, delete,
459
+ None, color1, self.draw_squares_option),
460
+ beginnerContext=(create, edit, copy, copy_to_clipboard, paste_from_clipboard, delete),
461
+ advancedContext=(create_mode, edit_mode, edit, copy, copy_to_clipboard,
462
+ paste_from_clipboard, delete, shape_line_color, shape_fill_color),
463
+ onLoadActive=(
464
+ close, create, create_mode, edit_mode),
465
+ onShapesPresent=(save_as, hide_all, show_all))
466
+
467
+ self.menus = Struct(
468
+ file=self.menu(get_str('menu_file')),
469
+ edit=self.menu(get_str('menu_edit')),
470
+ view=self.menu(get_str('menu_view')),
471
+ help=self.menu(get_str('menu_help')),
472
+ recentFiles=QMenu(get_str('menu_openRecent')),
473
+ labelList=label_menu)
474
+
475
+ # Auto saving : Enable auto saving if pressing next
476
+ self.auto_saving = QAction(get_str('autoSaveMode'), self)
477
+ self.auto_saving.setCheckable(True)
478
+ self.auto_saving.setChecked(settings.get(SETTING_AUTO_SAVE, False))
479
+ self.auto_saving.setToolTip(get_str('autoSaveModeDetail'))
480
+
481
+ # Auto-save timer (Issue #13)
482
+ self.auto_save_timer = QTimer(self)
483
+ self.auto_save_timer.timeout.connect(self._auto_save_triggered)
484
+
485
+ # Auto-save enabled toggle
486
+ self.auto_save_enabled = QAction(get_str('autoSaveEnabled'), self)
487
+ self.auto_save_enabled.setCheckable(True)
488
+ self.auto_save_enabled.setChecked(settings.get(SETTING_AUTO_SAVE_ENABLED, False))
489
+ self.auto_save_enabled.triggered.connect(self._toggle_auto_save_timer)
490
+ self.auto_save_enabled.setToolTip(get_str('autoSaveEnabledDetail'))
491
+
492
+ # Auto-save interval submenu
493
+ self.auto_save_interval_menu = QMenu(get_str('autoSaveInterval'), self)
494
+ self.auto_save_interval_group = QActionGroup(self)
495
+ self.auto_save_interval_group.setExclusive(True)
496
+ auto_save_intervals = [
497
+ (get_str('autoSave30s'), 30),
498
+ (get_str('autoSave1m'), 60),
499
+ (get_str('autoSave2m'), 120),
500
+ (get_str('autoSave5m'), 300),
501
+ ]
502
+ saved_interval = settings.get(SETTING_AUTO_SAVE_INTERVAL, 60)
503
+ for name, interval in auto_save_intervals:
504
+ interval_action = QAction(name, self)
505
+ interval_action.setCheckable(True)
506
+ interval_action.setData(interval)
507
+ interval_action.triggered.connect(self._set_auto_save_interval)
508
+ self.auto_save_interval_group.addAction(interval_action)
509
+ self.auto_save_interval_menu.addAction(interval_action)
510
+ if interval == saved_interval:
511
+ interval_action.setChecked(True)
512
+ # Default to 1 minute if nothing selected
513
+ if not any(a.isChecked() for a in self.auto_save_interval_group.actions()):
514
+ self.auto_save_interval_group.actions()[1].setChecked(True) # 1 minute
515
+
516
+ # Sync single class mode from PR#106
517
+ self.single_class_mode = QAction(get_str('singleClsMode'), self)
518
+ self.single_class_mode.setShortcut("Ctrl+Shift+S")
519
+ self.single_class_mode.setCheckable(True)
520
+ self.single_class_mode.setChecked(settings.get(SETTING_SINGLE_CLASS, False))
521
+ self.lastLabel = None
522
+ # Add option to enable/disable labels being displayed at the top of bounding boxes
523
+ self.display_label_option = QAction(get_str('displayLabel'), self)
524
+ self.display_label_option.setShortcut("Ctrl+Shift+P")
525
+ self.display_label_option.setCheckable(True)
526
+ self.display_label_option.setChecked(settings.get(SETTING_PAINT_LABEL, False))
527
+ self.display_label_option.triggered.connect(self.toggle_paint_labels_option)
528
+
529
+ # Icon size submenu for toolbar
530
+ self.icon_size_menu = QMenu(get_str('iconSize'), self)
531
+ self.icon_size_group = QActionGroup(self)
532
+ self.icon_size_group.setExclusive(True)
533
+ icon_sizes = [
534
+ (get_str('iconSizeSmall'), 16),
535
+ (get_str('iconSizeMedium'), 22),
536
+ (get_str('iconSizeLarge'), 28),
537
+ (get_str('iconSizeXLarge'), 36),
538
+ (get_str('iconSizeAuto'), 0), # 0 means auto-detect
539
+ ]
540
+ saved_icon_size = settings.get(SETTING_ICON_SIZE, 0)
541
+ for name, size in icon_sizes:
542
+ icon_action = QAction(name, self)
543
+ icon_action.setCheckable(True)
544
+ icon_action.setData(size)
545
+ icon_action.triggered.connect(self.change_icon_size)
546
+ self.icon_size_group.addAction(icon_action)
547
+ self.icon_size_menu.addAction(icon_action)
548
+ if size == saved_icon_size:
549
+ icon_action.setChecked(True)
550
+ # Default to auto if nothing selected
551
+ if not any(a.isChecked() for a in self.icon_size_group.actions()):
552
+ self.icon_size_group.actions()[-1].setChecked(True)
553
+
554
+ add_actions(self.menus.file,
555
+ (open, open_dir, change_save_dir, open_annotation, copy_prev_bounding, self.menus.recentFiles, save, save_format, save_as, close, reset_all, delete_image, quit))
556
+ add_actions(self.menus.help, (help_default, show_info, show_shortcut))
557
+ add_actions(self.menus.view, (
558
+ self.auto_saving,
559
+ self.auto_save_enabled,
560
+ self.single_class_mode,
561
+ self.display_label_option,
562
+ labels, advanced_mode, gallery_mode, None,
563
+ hide_all, show_all, None,
564
+ zoom_in, zoom_out, zoom_org, None,
565
+ fit_window, fit_width, None,
566
+ light_brighten, light_darken, light_org, None))
567
+ self.menus.view.addMenu(self.auto_save_interval_menu)
568
+ self.menus.view.addMenu(self.icon_size_menu)
569
+
570
+ self.menus.file.aboutToShow.connect(self.update_file_menu)
571
+
572
+ # Custom context menu for the canvas widget:
573
+ add_actions(self.canvas.menus[0], self.actions.beginnerContext)
574
+ add_actions(self.canvas.menus[1], (
575
+ action('&Copy here', self.copy_shape),
576
+ action('&Move here', self.move_shape)))
577
+
578
+ self.tools = self.toolbar('Tools')
579
+ self.tools.setStyleSheet(TOOLBAR_STYLE)
580
+
581
+ # Apply saved icon size setting
582
+ saved_icon_size = settings.get(SETTING_ICON_SIZE, 0)
583
+ if saved_icon_size > 0:
584
+ self.tools.update_icon_size(saved_icon_size)
585
+
586
+ # Create dropdown for file/directory operations
587
+ file_dropdown = DropdownToolButton(
588
+ text=get_str('openFile'),
589
+ icon=new_icon('file'),
590
+ actions=[open, open_dir, change_save_dir]
591
+ )
592
+
593
+ self.actions.beginner = (
594
+ file_dropdown, gallery_mode, None, open_next_image, open_prev_image, verify, save, save_format, None, create, copy, delete, None,
595
+ zoom_in, zoom, zoom_out, fit_window, fit_width, None,
596
+ brightness_dropdown)
597
+
598
+ self.actions.advanced = (
599
+ file_dropdown, gallery_mode, None, open_next_image, open_prev_image, save, save_format, None,
600
+ create_mode, edit_mode, None,
601
+ hide_all, show_all)
602
+
603
+ self.statusBar().showMessage('%s started.' % __appname__)
604
+ self.statusBar().show()
605
+
606
+ # Application state.
607
+ self.image = QImage()
608
+ self.file_path = ustr(default_filename)
609
+ self.last_open_dir = None
610
+ self.recent_files = []
611
+ self.max_recent = 7
612
+ self.line_color = None
613
+ self.fill_color = None
614
+ self.zoom_level = 100
615
+ self.fit_window = False
616
+ # Add Chris
617
+ self.difficult = False
618
+
619
+ # Fix the compatible issue for qt4 and qt5. Convert the QStringList to python list
620
+ if settings.get(SETTING_RECENT_FILES):
621
+ if have_qstring():
622
+ recent_file_qstring_list = settings.get(SETTING_RECENT_FILES)
623
+ self.recent_files = [ustr(i) for i in recent_file_qstring_list]
624
+ else:
625
+ self.recent_files = recent_file_qstring_list = settings.get(SETTING_RECENT_FILES)
626
+
627
+ size = settings.get(SETTING_WIN_SIZE, QSize(600, 500))
628
+ position = QPoint(0, 0)
629
+ saved_position = settings.get(SETTING_WIN_POSE, position)
630
+ # Fix the multiple monitors issue
631
+ for i in range(QApplication.desktop().screenCount()):
632
+ if QApplication.desktop().availableGeometry(i).contains(saved_position):
633
+ position = saved_position
634
+ break
635
+ self.resize(size)
636
+ self.move(position)
637
+ save_dir = ustr(settings.get(SETTING_SAVE_DIR, None))
638
+ self.last_open_dir = ustr(settings.get(SETTING_LAST_OPEN_DIR, None))
639
+ if self.default_save_dir is None and save_dir is not None and os.path.exists(save_dir):
640
+ self.default_save_dir = save_dir
641
+ self.statusBar().showMessage('%s started. Annotation will be saved to %s' %
642
+ (__appname__, self.default_save_dir))
643
+ self.statusBar().show()
644
+
645
+ self.restoreState(settings.get(SETTING_WIN_STATE, QByteArray()))
646
+ Shape.line_color = self.line_color = QColor(settings.get(SETTING_LINE_COLOR, DEFAULT_LINE_COLOR))
647
+ Shape.fill_color = self.fill_color = QColor(settings.get(SETTING_FILL_COLOR, DEFAULT_FILL_COLOR))
648
+ self.canvas.set_drawing_color(self.line_color)
649
+ # Add chris
650
+ Shape.difficult = self.difficult
651
+
652
+ def xbool(x):
653
+ if isinstance(x, QVariant):
654
+ return x.toBool()
655
+ return bool(x)
656
+
657
+ if xbool(settings.get(SETTING_ADVANCE_MODE, False)):
658
+ self.actions.advancedMode.setChecked(True)
659
+ self.toggle_advanced_mode()
660
+
661
+ if xbool(settings.get(SETTING_GALLERY_MODE, False)):
662
+ self.actions.galleryMode.setChecked(True)
663
+ self.toggle_gallery_mode()
664
+
665
+ # Populate the File menu dynamically.
666
+ self.update_file_menu()
667
+
668
+ # Since loading the file may take some time, make sure it runs in the background.
669
+ if self.file_path and os.path.isdir(self.file_path):
670
+ self.queue_event(partial(self.import_dir_images, self.file_path or ""))
671
+ elif self.file_path:
672
+ self.queue_event(partial(self.load_file, self.file_path or ""))
673
+
674
+ # Callbacks:
675
+ self.zoom_widget.valueChanged.connect(self.paint_canvas)
676
+ self.zoom_widget.valueChanged.connect(self.update_zoom_display)
677
+ self.light_widget.valueChanged.connect(self.paint_canvas)
678
+
679
+ self.populate_mode_actions()
680
+
681
+ # Status bar permanent widgets (left to right)
682
+ # Image counter
683
+ self.label_image_count = QLabel('Image: 0 / 0')
684
+ self.label_image_count.setMinimumWidth(100)
685
+ self.statusBar().addPermanentWidget(self.label_image_count)
686
+
687
+ # Annotation count
688
+ self.label_box_count = QLabel('Boxes: 0')
689
+ self.label_box_count.setMinimumWidth(70)
690
+ self.statusBar().addPermanentWidget(self.label_box_count)
691
+
692
+ # Zoom level
693
+ self.label_zoom = QLabel('Zoom: 100%')
694
+ self.label_zoom.setMinimumWidth(80)
695
+ self.statusBar().addPermanentWidget(self.label_zoom)
696
+
697
+ # Save status indicator
698
+ self.label_save_status = QLabel('●')
699
+ self.label_save_status.setStyleSheet('color: green; font-size: 14px;')
700
+ self.label_save_status.setToolTip('Saved')
701
+ self.statusBar().addPermanentWidget(self.label_save_status)
702
+
703
+ # Display cursor coordinates at the right of status bar
704
+ self.label_coordinates = QLabel('')
705
+ self.statusBar().addPermanentWidget(self.label_coordinates)
706
+
707
+ # Open Dir if default file
708
+ if self.file_path and os.path.isdir(self.file_path):
709
+ self.open_dir_dialog(dir_path=self.file_path, silent=True)
710
+
711
+ # Start auto-save timer if enabled (Issue #13)
712
+ if self.auto_save_enabled.isChecked():
713
+ self._toggle_auto_save_timer()
714
+
715
+ def keyReleaseEvent(self, event):
716
+ if event.key() == Qt.Key_Control:
717
+ self.canvas.set_drawing_shape_to_square(False)
718
+
719
+ def keyPressEvent(self, event):
720
+ if event.key() == Qt.Key_Control:
721
+ # Draw rectangle if Ctrl is pressed
722
+ self.canvas.set_drawing_shape_to_square(True)
723
+
724
+ # Support Functions #
725
+ def set_format(self, save_format):
726
+ if save_format == FORMAT_PASCALVOC:
727
+ self.actions.save_format.setText(FORMAT_PASCALVOC)
728
+ self.actions.save_format.setIcon(new_icon("format_voc"))
729
+ self.label_file_format = LabelFileFormat.PASCAL_VOC
730
+ LabelFile.suffix = XML_EXT
731
+
732
+ elif save_format == FORMAT_YOLO:
733
+ self.actions.save_format.setText(FORMAT_YOLO)
734
+ self.actions.save_format.setIcon(new_icon("format_yolo"))
735
+ self.label_file_format = LabelFileFormat.YOLO
736
+ LabelFile.suffix = TXT_EXT
737
+
738
+ elif save_format == FORMAT_CREATEML:
739
+ self.actions.save_format.setText(FORMAT_CREATEML)
740
+ self.actions.save_format.setIcon(new_icon("format_createml"))
741
+ self.label_file_format = LabelFileFormat.CREATE_ML
742
+ LabelFile.suffix = JSON_EXT
743
+
744
+ def change_format(self):
745
+ # Determine the new format
746
+ if self.label_file_format == LabelFileFormat.PASCAL_VOC:
747
+ new_format = FORMAT_YOLO
748
+ warning = "Switching to YOLO format.\n\nNote: The 'difficult' flag will be lost."
749
+ elif self.label_file_format == LabelFileFormat.YOLO:
750
+ new_format = FORMAT_CREATEML
751
+ warning = "Switching to CreateML format."
752
+ elif self.label_file_format == LabelFileFormat.CREATE_ML:
753
+ new_format = FORMAT_PASCALVOC
754
+ warning = "Switching to PASCAL VOC format."
755
+ else:
756
+ raise ValueError('Unknown label file format.')
757
+
758
+ # Show confirmation dialog
759
+ msg = QMessageBox()
760
+ msg.setIcon(QMessageBox.Warning)
761
+ msg.setWindowTitle("Change Annotation Format")
762
+ msg.setText(warning)
763
+ msg.setInformativeText("This will only affect new saves. Existing annotation files will not be converted.")
764
+ msg.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel)
765
+ msg.setDefaultButton(QMessageBox.Ok)
766
+
767
+ if msg.exec_() == QMessageBox.Ok:
768
+ self.set_format(new_format)
769
+ self.set_dirty()
770
+ self.status(f"Format changed to {new_format}")
771
+
772
+ def no_shapes(self):
773
+ return not self.items_to_shapes
774
+
775
+ def toggle_advanced_mode(self, value=True):
776
+ self._beginner = not value
777
+ self.canvas.set_editing(True)
778
+ self.populate_mode_actions()
779
+ self.edit_button.setVisible(not value)
780
+ if value:
781
+ self.actions.createMode.setEnabled(True)
782
+ self.actions.editMode.setEnabled(False)
783
+ self.dock.setFeatures(self.dock.features() | self.dock_features)
784
+ else:
785
+ self.dock.setFeatures(self.dock.features() ^ self.dock_features)
786
+
787
+ def toggle_gallery_mode(self, value=True):
788
+ """Toggle between normal view and full-screen gallery mode."""
789
+ # Guard against rapid toggling
790
+ if hasattr(self, '_toggling_gallery') and self._toggling_gallery:
791
+ return
792
+ self._toggling_gallery = True
793
+ try:
794
+ self.gallery_mode_enabled = value
795
+
796
+ if value:
797
+ # Cleanup any existing gallery first
798
+ if hasattr(self, 'full_gallery') and self.full_gallery:
799
+ try:
800
+ self.full_gallery.image_selected.disconnect()
801
+ self.full_gallery.image_activated.disconnect()
802
+ except TypeError:
803
+ pass # Already disconnected
804
+ self.full_gallery = None
805
+ if hasattr(self, 'gallery_window') and self.gallery_window:
806
+ self.gallery_window.close()
807
+ self.gallery_window = None
808
+
809
+ # Create full-screen gallery as a regular window (not dialog)
810
+ self.gallery_window = QMainWindow(self)
811
+ self.gallery_window.setWindowTitle("Gallery Mode - Double-click to select, Press Escape or close to exit")
812
+
813
+ self.full_gallery = GalleryWidget(show_size_slider=True)
814
+ self.full_gallery.set_save_dir(self.default_save_dir)
815
+ self.full_gallery.set_image_list(self.m_img_list)
816
+ self.full_gallery.image_selected.connect(self.gallery_image_selected)
817
+ self.full_gallery.image_activated.connect(self._exit_gallery_and_load)
818
+ # Defer status refresh to allow gallery to display first
819
+ QTimer.singleShot(0, self._refresh_full_gallery_statuses)
820
+
821
+ # Select current image in full gallery
822
+ if self.file_path:
823
+ self.full_gallery.select_image(self.file_path)
824
+
825
+ self.gallery_window.setCentralWidget(self.full_gallery)
826
+
827
+ # Show maximized
828
+ self.gallery_window.showMaximized()
829
+ else:
830
+ # Close gallery window
831
+ if hasattr(self, 'full_gallery') and self.full_gallery:
832
+ try:
833
+ self.full_gallery.image_selected.disconnect()
834
+ self.full_gallery.image_activated.disconnect()
835
+ except TypeError:
836
+ pass
837
+ self.full_gallery = None
838
+ if hasattr(self, 'gallery_window') and self.gallery_window:
839
+ self.gallery_window.close()
840
+ self.gallery_window = None
841
+ finally:
842
+ self._toggling_gallery = False
843
+
844
+ def _exit_gallery_and_load(self, image_path):
845
+ """Exit gallery mode and load the selected image."""
846
+ self.actions.galleryMode.setChecked(False)
847
+ self.toggle_gallery_mode(False)
848
+ self.gallery_image_activated(image_path)
849
+
850
+ def _refresh_full_gallery_statuses(self):
851
+ """Update statuses for full-screen gallery with batch processing."""
852
+ if not (hasattr(self, 'full_gallery') and self.full_gallery):
853
+ return
854
+
855
+ # Use cached statuses for instant update, collect uncached for batch processing
856
+ cached_statuses = {}
857
+ uncached = []
858
+ for img_path in self.m_img_list:
859
+ if img_path in self._annotation_status_cache:
860
+ cached_statuses[img_path] = self._annotation_status_cache[img_path]
861
+ else:
862
+ uncached.append(img_path)
863
+
864
+ # Apply cached statuses immediately
865
+ if cached_statuses:
866
+ self.full_gallery.update_all_statuses(cached_statuses)
867
+
868
+ # Process uncached images in batches to keep UI responsive
869
+ if uncached:
870
+ self._process_full_gallery_status_batch(uncached, 0)
871
+
872
+ def _process_full_gallery_status_batch(self, images, start_idx, batch_size=50):
873
+ """Process status updates in batches to avoid UI freeze."""
874
+ if not (hasattr(self, 'full_gallery') and self.full_gallery):
875
+ return # Gallery was closed
876
+
877
+ statuses = {}
878
+ end_idx = min(start_idx + batch_size, len(images))
879
+ for img_path in images[start_idx:end_idx]:
880
+ statuses[img_path] = self._get_annotation_status(img_path)
881
+
882
+ self.full_gallery.update_all_statuses(statuses)
883
+
884
+ # Schedule next batch if more images remain
885
+ if end_idx < len(images):
886
+ QTimer.singleShot(0, lambda: self._process_full_gallery_status_batch(
887
+ images, end_idx, batch_size))
888
+
889
+ def populate_mode_actions(self):
890
+ if self.beginner():
891
+ tool, menu = self.actions.beginner, self.actions.beginnerContext
892
+ else:
893
+ tool, menu = self.actions.advanced, self.actions.advancedContext
894
+ self.tools.clear()
895
+ add_actions(self.tools, tool)
896
+ self.canvas.menus[0].clear()
897
+ add_actions(self.canvas.menus[0], menu)
898
+ self.menus.edit.clear()
899
+ actions = (self.actions.create,) if self.beginner()\
900
+ else (self.actions.createMode, self.actions.editMode)
901
+ add_actions(self.menus.edit, actions + self.actions.editMenu)
902
+
903
+ def set_beginner(self):
904
+ self.tools.clear()
905
+ add_actions(self.tools, self.actions.beginner)
906
+ self.tools.add_expand_button()
907
+ # Restore expanded state
908
+ if self.settings.get(SETTING_TOOLBAR_EXPANDED, False):
909
+ self.tools.set_expanded(True)
910
+
911
+ def set_advanced(self):
912
+ self.tools.clear()
913
+ add_actions(self.tools, self.actions.advanced)
914
+ self.tools.add_expand_button()
915
+ # Restore expanded state
916
+ if self.settings.get(SETTING_TOOLBAR_EXPANDED, False):
917
+ self.tools.set_expanded(True)
918
+
919
+ def set_dirty(self):
920
+ self.dirty = True
921
+ self.actions.save.setEnabled(True)
922
+ self.update_save_status(saved=False)
923
+ self.update_box_count()
924
+
925
+ def set_clean(self):
926
+ self.dirty = False
927
+ self.actions.save.setEnabled(False)
928
+ self.actions.create.setEnabled(True)
929
+ self.update_save_status(saved=True)
930
+
931
+ def toggle_actions(self, value=True):
932
+ """Enable/Disable widgets which depend on an opened image."""
933
+ for z in self.actions.zoomActions:
934
+ z.setEnabled(value)
935
+ for z in self.actions.lightActions:
936
+ z.setEnabled(value)
937
+ for action in self.actions.onLoadActive:
938
+ action.setEnabled(value)
939
+ # Enable paste if clipboard has shapes and image is loaded
940
+ if value and self.clipboard_shapes:
941
+ self.actions.pasteFromClipboard.setEnabled(True)
942
+ # Enable copy all if there are shapes
943
+ if value and self.canvas.shapes:
944
+ self.actions.copyAllToClipboard.setEnabled(True)
945
+
946
+ def queue_event(self, function):
947
+ QTimer.singleShot(0, function)
948
+
949
+ def status(self, message, delay=5000):
950
+ self.statusBar().showMessage(message, delay)
951
+
952
+ def update_status_bar(self):
953
+ """Update all status bar widgets."""
954
+ self.update_image_count()
955
+ self.update_box_count()
956
+ self.update_zoom_display()
957
+
958
+ def update_image_count(self):
959
+ """Update image counter in status bar."""
960
+ if self.m_img_list and self.file_path:
961
+ idx = self._path_to_idx.get(self.file_path, -1) + 1
962
+ self.label_image_count.setText(f'Image: {idx} / {len(self.m_img_list)}')
963
+ else:
964
+ self.label_image_count.setText('Image: 0 / 0')
965
+
966
+ def update_box_count(self):
967
+ """Update annotation count in status bar."""
968
+ count = len(self.canvas.shapes) if self.canvas else 0
969
+ self.label_box_count.setText(f'Boxes: {count}')
970
+
971
+ def update_zoom_display(self):
972
+ """Update zoom level in status bar."""
973
+ if self.zoom_widget:
974
+ self.label_zoom.setText(f'Zoom: {self.zoom_widget.value()}%')
975
+
976
+ def update_save_status(self, saved=True):
977
+ """Update save status indicator in status bar."""
978
+ if saved:
979
+ self.label_save_status.setStyleSheet('color: green; font-size: 14px;')
980
+ self.label_save_status.setToolTip('Saved')
981
+ else:
982
+ self.label_save_status.setStyleSheet('color: orange; font-size: 14px;')
983
+ self.label_save_status.setToolTip('Unsaved changes')
984
+
985
+ def reset_state(self):
986
+ self.items_to_shapes.clear()
987
+ self.shapes_to_items.clear()
988
+ self.label_list.clear()
989
+ self.file_path = None
990
+ self.image_data = None
991
+ self.label_file = None
992
+ self.canvas.reset_state()
993
+ self.label_coordinates.clear()
994
+ self.combo_box.cb.clear()
995
+ # Clear undo stack when loading new file
996
+ self.undo_stack.clear()
997
+ # Reset status bar widgets
998
+ self.label_box_count.setText('Boxes: 0')
999
+ self.update_save_status(saved=True)
1000
+
1001
+ def current_item(self):
1002
+ items = self.label_list.selectedItems()
1003
+ if items:
1004
+ return items[0]
1005
+ return None
1006
+
1007
+ def add_recent_file(self, file_path):
1008
+ if file_path in self.recent_files:
1009
+ self.recent_files.remove(file_path)
1010
+ elif len(self.recent_files) >= self.max_recent:
1011
+ self.recent_files.pop()
1012
+ self.recent_files.insert(0, file_path)
1013
+
1014
+ def beginner(self):
1015
+ return self._beginner
1016
+
1017
+ def advanced(self):
1018
+ return not self.beginner()
1019
+
1020
+ def show_tutorial_dialog(self, browser='default', link=None):
1021
+ if link is None:
1022
+ link = self.screencast
1023
+
1024
+ if browser.lower() == 'default':
1025
+ wb.open(link, new=2)
1026
+ elif browser.lower() == 'chrome' and self.os_name == 'Windows':
1027
+ if shutil.which(browser.lower()): # 'chrome' not in wb._browsers in windows
1028
+ wb.register('chrome', None, wb.BackgroundBrowser('chrome'))
1029
+ else:
1030
+ chrome_path="D:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
1031
+ if os.path.isfile(chrome_path):
1032
+ wb.register('chrome', None, wb.BackgroundBrowser(chrome_path))
1033
+ try:
1034
+ wb.get('chrome').open(link, new=2)
1035
+ except (wb.Error, KeyError):
1036
+ wb.open(link, new=2)
1037
+ elif browser.lower() in wb._browsers:
1038
+ wb.get(browser.lower()).open(link, new=2)
1039
+
1040
+ def show_default_tutorial_dialog(self):
1041
+ self.show_tutorial_dialog(browser='default')
1042
+
1043
+ def show_info_dialog(self):
1044
+ from libs.__init__ import __version__
1045
+ msg = u'Name:{0} \nApp Version:{1} \n{2} '.format(__appname__, __version__, sys.version_info)
1046
+ QMessageBox.information(self, u'Information', msg)
1047
+
1048
+ def show_shortcuts_dialog(self):
1049
+ self.show_tutorial_dialog(browser='default', link='https://github.com/tzutalin/labelImg#Hotkeys')
1050
+
1051
+ def create_shape(self):
1052
+ assert self.beginner()
1053
+ self.canvas.set_editing(False)
1054
+ self.actions.create.setEnabled(False)
1055
+
1056
+ def toggle_drawing_sensitive(self, drawing=True):
1057
+ """In the middle of drawing, toggling between modes should be disabled."""
1058
+ self.actions.editMode.setEnabled(not drawing)
1059
+ if not drawing and self.beginner():
1060
+ # Cancel creation.
1061
+ print('Cancel creation.')
1062
+ self.canvas.set_editing(True)
1063
+ self.canvas.restore_cursor()
1064
+ self.actions.create.setEnabled(True)
1065
+
1066
+ def toggle_draw_mode(self, edit=True):
1067
+ self.canvas.set_editing(edit)
1068
+ self.actions.createMode.setEnabled(edit)
1069
+ self.actions.editMode.setEnabled(not edit)
1070
+
1071
+ def set_create_mode(self):
1072
+ assert self.advanced()
1073
+ self.toggle_draw_mode(False)
1074
+
1075
+ def set_edit_mode(self):
1076
+ assert self.advanced()
1077
+ self.toggle_draw_mode(True)
1078
+ self.label_selection_changed()
1079
+
1080
+ def update_file_menu(self):
1081
+ curr_file_path = self.file_path
1082
+
1083
+ def exists(filename):
1084
+ return os.path.exists(filename)
1085
+ menu = self.menus.recentFiles
1086
+ menu.clear()
1087
+ files = [f for f in self.recent_files if f !=
1088
+ curr_file_path and exists(f)]
1089
+ for i, f in enumerate(files):
1090
+ icon = new_icon('labels')
1091
+ action = QAction(
1092
+ icon, '&%d %s' % (i + 1, QFileInfo(f).fileName()), self)
1093
+ action.triggered.connect(partial(self.load_recent, f))
1094
+ menu.addAction(action)
1095
+
1096
+ def pop_label_list_menu(self, point):
1097
+ self.menus.labelList.exec_(self.label_list.mapToGlobal(point))
1098
+
1099
+ def edit_label(self):
1100
+ if not self.canvas.editing():
1101
+ return
1102
+ item = self.current_item()
1103
+ if not item:
1104
+ return
1105
+ text = self.label_dialog.pop_up(item.text())
1106
+ if text is not None:
1107
+ item.setText(text)
1108
+ item.setBackground(generate_color_by_text(text))
1109
+ self.set_dirty()
1110
+ self.update_combo_box()
1111
+
1112
+ # Tzutalin 20160906 : Add file list and dock to move faster
1113
+ def file_item_double_clicked(self, item=None):
1114
+ item_path = ustr(item.text())
1115
+ self.cur_img_idx = self._path_to_idx.get(item_path, 0)
1116
+ filename = self.m_img_list[self.cur_img_idx]
1117
+ if filename:
1118
+ self.load_file(filename)
1119
+
1120
+ def file_item_clicked(self, item=None):
1121
+ """Handle single click on file list item - sync gallery selection."""
1122
+ # Skip if we're already in a gallery selection operation
1123
+ if hasattr(self, '_selecting_gallery') and self._selecting_gallery:
1124
+ return
1125
+ if item is not None:
1126
+ item_path = ustr(item.text())
1127
+ if item_path in self._path_to_idx:
1128
+ self.cur_img_idx = self._path_to_idx[item_path]
1129
+ self.gallery_widget.select_image(item_path)
1130
+
1131
+ def gallery_image_selected(self, image_path):
1132
+ """Handle single click on gallery thumbnail - sync all views."""
1133
+ # Prevent recursive calls
1134
+ if hasattr(self, '_selecting_gallery') and self._selecting_gallery:
1135
+ return
1136
+ self._selecting_gallery = True
1137
+ try:
1138
+ if image_path in self._path_to_idx:
1139
+ self.cur_img_idx = self._path_to_idx[image_path]
1140
+ # Sync list selection - block signals to prevent triggering file_item_clicked
1141
+ self.file_list_widget.blockSignals(True)
1142
+ for i in range(self.file_list_widget.count()):
1143
+ item = self.file_list_widget.item(i)
1144
+ if ustr(item.text()) == image_path:
1145
+ self.file_list_widget.setCurrentItem(item)
1146
+ break
1147
+ self.file_list_widget.blockSignals(False)
1148
+ # Sync all gallery selections
1149
+ self.gallery_widget.select_image(image_path)
1150
+ if hasattr(self, 'full_gallery') and self.full_gallery:
1151
+ self.full_gallery.select_image(image_path)
1152
+ finally:
1153
+ self._selecting_gallery = False
1154
+
1155
+ def gallery_image_activated(self, image_path):
1156
+ """Handle double-click on gallery thumbnail - load image."""
1157
+ if image_path in self._path_to_idx:
1158
+ self.cur_img_idx = self._path_to_idx[image_path]
1159
+ self.load_file(image_path)
1160
+
1161
+ def on_file_view_tab_changed(self, index):
1162
+ """Handle tab switch between list and gallery view."""
1163
+ if index == 1: # Gallery tab
1164
+ self._refresh_gallery_statuses()
1165
+
1166
+ def _get_annotation_status(self, image_path, use_cache=True):
1167
+ """Determine annotation status for an image with optional caching."""
1168
+ # Check cache first for O(1) lookup
1169
+ if use_cache and image_path in self._annotation_status_cache:
1170
+ return self._annotation_status_cache[image_path]
1171
+
1172
+ basename = os.path.splitext(os.path.basename(image_path))[0]
1173
+
1174
+ # Determine annotation directory
1175
+ if self.default_save_dir is not None:
1176
+ ann_dir = self.default_save_dir
1177
+ else:
1178
+ ann_dir = os.path.dirname(image_path)
1179
+
1180
+ # Check for annotation files
1181
+ xml_path = os.path.join(ann_dir, basename + XML_EXT)
1182
+ txt_path = os.path.join(ann_dir, basename + TXT_EXT)
1183
+ json_path = os.path.join(ann_dir, 'annotations.json')
1184
+
1185
+ has_labels = False
1186
+ verified = False
1187
+
1188
+ # Check PASCAL VOC
1189
+ if os.path.isfile(xml_path):
1190
+ has_labels = True
1191
+ try:
1192
+ reader = PascalVocReader(xml_path)
1193
+ verified = reader.verified
1194
+ except Exception:
1195
+ pass
1196
+ # Check YOLO
1197
+ elif os.path.isfile(txt_path):
1198
+ has_labels = os.path.getsize(txt_path) > 0
1199
+ # Check CreateML
1200
+ elif os.path.isfile(json_path):
1201
+ try:
1202
+ import json
1203
+ with open(json_path, 'r') as f:
1204
+ data = json.load(f)
1205
+ for item in data:
1206
+ if item.get('image') == os.path.basename(image_path):
1207
+ has_labels = len(item.get('annotations', [])) > 0
1208
+ verified = item.get('verified', False)
1209
+ break
1210
+ except Exception:
1211
+ pass
1212
+
1213
+ if verified:
1214
+ status = AnnotationStatus.VERIFIED
1215
+ elif has_labels:
1216
+ status = AnnotationStatus.HAS_LABELS
1217
+ else:
1218
+ status = AnnotationStatus.NO_LABELS
1219
+
1220
+ # Cache the result
1221
+ self._annotation_status_cache[image_path] = status
1222
+ return status
1223
+
1224
+ def _invalidate_status_cache(self, image_path=None):
1225
+ """Invalidate annotation status cache for a path or all paths."""
1226
+ if image_path:
1227
+ self._annotation_status_cache.pop(image_path, None)
1228
+ else:
1229
+ self._annotation_status_cache.clear()
1230
+
1231
+ def _refresh_gallery_statuses(self):
1232
+ """Update all gallery thumbnail statuses."""
1233
+ statuses = {}
1234
+ for img_path in self.m_img_list:
1235
+ statuses[img_path] = self._get_annotation_status(img_path)
1236
+ self.gallery_widget.update_all_statuses(statuses)
1237
+
1238
+ def _update_current_image_gallery_status(self):
1239
+ """Update gallery status for current image after save/verify."""
1240
+ if self.file_path:
1241
+ # Invalidate cache for this file to get fresh status
1242
+ self._invalidate_status_cache(self.file_path)
1243
+ status = self._get_annotation_status(self.file_path)
1244
+ self.gallery_widget.update_status(self.file_path, status)
1245
+ # Also update full-screen gallery if active
1246
+ if hasattr(self, 'full_gallery') and self.full_gallery:
1247
+ self.full_gallery.update_status(self.file_path, status)
1248
+
1249
+ # Add chris
1250
+ def button_state(self, item=None):
1251
+ """ Function to handle difficult examples
1252
+ Update on each object """
1253
+ if not self.canvas.editing():
1254
+ return
1255
+
1256
+ item = self.current_item()
1257
+ if not item: # If not selected Item, take the first one
1258
+ item = self.label_list.item(self.label_list.count() - 1)
1259
+
1260
+ difficult = self.diffc_button.isChecked()
1261
+
1262
+ shape = self.items_to_shapes.get(item)
1263
+ if shape is None:
1264
+ return
1265
+
1266
+ # Checked and Update
1267
+ if difficult != shape.difficult:
1268
+ shape.difficult = difficult
1269
+ self.set_dirty()
1270
+ else: # User probably changed item visibility
1271
+ self.canvas.set_shape_visible(shape, item.checkState() == Qt.Checked)
1272
+
1273
+ # React to canvas signals.
1274
+ def shape_selection_changed(self, selected=False):
1275
+ if self._no_selection_slot:
1276
+ self._no_selection_slot = False
1277
+ else:
1278
+ shape = self.canvas.selected_shape
1279
+ if shape:
1280
+ self.shapes_to_items[shape].setSelected(True)
1281
+ else:
1282
+ self.label_list.clearSelection()
1283
+ self.actions.delete.setEnabled(selected)
1284
+ self.actions.copy.setEnabled(selected)
1285
+ self.actions.copyToClipboard.setEnabled(selected)
1286
+ self.actions.edit.setEnabled(selected)
1287
+ self.actions.shapeLineColor.setEnabled(selected)
1288
+ self.actions.shapeFillColor.setEnabled(selected)
1289
+ # Enable paste if clipboard has shapes
1290
+ self.actions.pasteFromClipboard.setEnabled(len(self.clipboard_shapes) > 0)
1291
+ # Enable copy all if there are shapes
1292
+ self.actions.copyAllToClipboard.setEnabled(len(self.canvas.shapes) > 0)
1293
+
1294
+ def add_label(self, shape):
1295
+ shape.paint_label = self.display_label_option.isChecked()
1296
+ item = HashableQListWidgetItem(shape.label)
1297
+ item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
1298
+ item.setCheckState(Qt.Checked)
1299
+ item.setBackground(generate_color_by_text(shape.label))
1300
+ self.items_to_shapes[item] = shape
1301
+ self.shapes_to_items[shape] = item
1302
+ self.label_list.addItem(item)
1303
+ for action in self.actions.onShapesPresent:
1304
+ action.setEnabled(True)
1305
+ self.update_combo_box()
1306
+
1307
+ def remove_label(self, shape):
1308
+ if shape is None:
1309
+ # print('rm empty label')
1310
+ return
1311
+ item = self.shapes_to_items[shape]
1312
+ self.label_list.takeItem(self.label_list.row(item))
1313
+ del self.shapes_to_items[shape]
1314
+ del self.items_to_shapes[item]
1315
+ self.update_combo_box()
1316
+
1317
+ def load_labels(self, shapes):
1318
+ s = []
1319
+ # Scale factor for converting original coords to display coords (Issue #31)
1320
+ scale = self._image_scale_factor if hasattr(self, '_image_scale_factor') else 1.0
1321
+
1322
+ for label, points, line_color, fill_color, difficult in shapes:
1323
+ shape = Shape(label=label)
1324
+ for x, y in points:
1325
+ # Scale coordinates from original to display space
1326
+ x = x * scale
1327
+ y = y * scale
1328
+
1329
+ # Ensure the labels are within the bounds of the image. If not, fix them.
1330
+ x, y, snapped = self.canvas.snap_point_to_canvas(x, y)
1331
+ if snapped:
1332
+ self.set_dirty()
1333
+
1334
+ shape.add_point(QPointF(x, y))
1335
+ shape.difficult = difficult
1336
+ shape.close()
1337
+ s.append(shape)
1338
+
1339
+ if line_color:
1340
+ shape.line_color = QColor(*line_color)
1341
+ else:
1342
+ shape.line_color = generate_color_by_text(label)
1343
+
1344
+ if fill_color:
1345
+ shape.fill_color = QColor(*fill_color)
1346
+ else:
1347
+ shape.fill_color = generate_color_by_text(label)
1348
+
1349
+ self.add_label(shape)
1350
+ self.update_combo_box()
1351
+ self.canvas.load_shapes(s)
1352
+
1353
+ def update_combo_box(self):
1354
+ # Get the unique labels and add them to the Combobox.
1355
+ items_text_list = [str(self.label_list.item(i).text()) for i in range(self.label_list.count())]
1356
+
1357
+ unique_text_list = list(set(items_text_list))
1358
+ # Add a null row for showing all the labels
1359
+ unique_text_list.append("")
1360
+ unique_text_list.sort()
1361
+
1362
+ self.combo_box.update_items(unique_text_list)
1363
+
1364
+ def save_labels(self, annotation_file_path):
1365
+ annotation_file_path = ustr(annotation_file_path)
1366
+ if self.label_file is None:
1367
+ self.label_file = LabelFile()
1368
+ self.label_file.verified = self.canvas.verified
1369
+
1370
+ # Scale factor for converting display coords to original coords (Issue #31)
1371
+ inv_scale = 1.0 / self._image_scale_factor if hasattr(self, '_image_scale_factor') and self._image_scale_factor != 0 else 1.0
1372
+
1373
+ def format_shape(s):
1374
+ # Scale coordinates from display space to original image space
1375
+ scaled_points = [(p.x() * inv_scale, p.y() * inv_scale) for p in s.points]
1376
+ return dict(label=s.label,
1377
+ line_color=s.line_color.getRgb(),
1378
+ fill_color=s.fill_color.getRgb(),
1379
+ points=scaled_points,
1380
+ difficult=s.difficult)
1381
+
1382
+ shapes = [format_shape(shape) for shape in self.canvas.shapes]
1383
+ # Can add different annotation formats here
1384
+ try:
1385
+ if self.label_file_format == LabelFileFormat.PASCAL_VOC:
1386
+ if annotation_file_path[-4:].lower() != ".xml":
1387
+ annotation_file_path += XML_EXT
1388
+ self.label_file.save_pascal_voc_format(annotation_file_path, shapes, self.file_path, self.image_data,
1389
+ self.line_color.getRgb(), self.fill_color.getRgb())
1390
+ elif self.label_file_format == LabelFileFormat.YOLO:
1391
+ if annotation_file_path[-4:].lower() != ".txt":
1392
+ annotation_file_path += TXT_EXT
1393
+ self.label_file.save_yolo_format(annotation_file_path, shapes, self.file_path, self.image_data, self.label_hist,
1394
+ self.line_color.getRgb(), self.fill_color.getRgb())
1395
+ elif self.label_file_format == LabelFileFormat.CREATE_ML:
1396
+ if annotation_file_path[-5:].lower() != ".json":
1397
+ annotation_file_path += JSON_EXT
1398
+ self.label_file.save_create_ml_format(annotation_file_path, shapes, self.file_path, self.image_data,
1399
+ self.label_hist, self.line_color.getRgb(), self.fill_color.getRgb())
1400
+ else:
1401
+ self.label_file.save(annotation_file_path, shapes, self.file_path, self.image_data,
1402
+ self.line_color.getRgb(), self.fill_color.getRgb())
1403
+ print('Image:{0} -> Annotation:{1}'.format(self.file_path, annotation_file_path))
1404
+ return True
1405
+ except LabelFileError as e:
1406
+ self.error_message(u'Error saving label data', u'<b>%s</b>' % e)
1407
+ return False
1408
+
1409
+ def copy_selected_shape(self):
1410
+ shape = self.canvas.copy_selected_shape()
1411
+ self.add_label(shape)
1412
+
1413
+ # Push command for undo support (shape already created, so just push)
1414
+ cmd = CreateShapeCommand(self, shape)
1415
+ self.undo_stack.push(cmd)
1416
+
1417
+ # fix copy and delete
1418
+ self.shape_selection_changed(True)
1419
+
1420
+ def copy_to_clipboard(self):
1421
+ """Copy selected shape to clipboard for pasting across images."""
1422
+ if self.canvas.selected_shape is None:
1423
+ return
1424
+ # Store a copy of the selected shape
1425
+ self.clipboard_shapes = [self.canvas.selected_shape.copy()]
1426
+ self.actions.pasteFromClipboard.setEnabled(True)
1427
+ self.statusBar().showMessage(f'Copied 1 annotation to clipboard', 3000)
1428
+
1429
+ def copy_all_to_clipboard(self):
1430
+ """Copy all shapes to clipboard for pasting across images."""
1431
+ if not self.canvas.shapes:
1432
+ return
1433
+ # Store copies of all shapes
1434
+ self.clipboard_shapes = [shape.copy() for shape in self.canvas.shapes]
1435
+ self.actions.pasteFromClipboard.setEnabled(True)
1436
+ self.statusBar().showMessage(f'Copied {len(self.clipboard_shapes)} annotations to clipboard', 3000)
1437
+
1438
+ def paste_from_clipboard(self):
1439
+ """Paste shapes from clipboard to current image."""
1440
+ if not self.clipboard_shapes:
1441
+ return
1442
+ if not self.canvas.pixmap or self.canvas.pixmap.isNull():
1443
+ return
1444
+
1445
+ for clipboard_shape in self.clipboard_shapes:
1446
+ # Create a new copy for each paste
1447
+ shape = clipboard_shape.copy()
1448
+ # Add shape to canvas
1449
+ self.canvas.shapes.append(shape)
1450
+ self.add_label(shape)
1451
+ # Push command for undo support
1452
+ cmd = CreateShapeCommand(self, shape)
1453
+ self.undo_stack.push(cmd)
1454
+
1455
+ self.set_dirty()
1456
+ self.canvas.update()
1457
+ self.update_box_count()
1458
+ self.statusBar().showMessage(f'Pasted {len(self.clipboard_shapes)} annotations', 3000)
1459
+
1460
+ def combo_selection_changed(self, index):
1461
+ text = self.combo_box.cb.itemText(index)
1462
+ for i in range(self.label_list.count()):
1463
+ if text == "":
1464
+ self.label_list.item(i).setCheckState(2)
1465
+ elif text != self.label_list.item(i).text():
1466
+ self.label_list.item(i).setCheckState(0)
1467
+ else:
1468
+ self.label_list.item(i).setCheckState(2)
1469
+
1470
+ def default_label_combo_selection_changed(self, index):
1471
+ self.default_label=self.label_hist[index]
1472
+
1473
+ def label_selection_changed(self):
1474
+ item = self.current_item()
1475
+ if item and self.canvas.editing():
1476
+ self._no_selection_slot = True
1477
+ self.canvas.select_shape(self.items_to_shapes[item])
1478
+ shape = self.items_to_shapes[item]
1479
+ # Add Chris
1480
+ self.diffc_button.setChecked(shape.difficult)
1481
+
1482
+ def label_item_changed(self, item):
1483
+ shape = self.items_to_shapes[item]
1484
+ label = item.text()
1485
+ if label != shape.label:
1486
+ old_label = shape.label
1487
+ shape.label = item.text()
1488
+ shape.line_color = generate_color_by_text(shape.label)
1489
+
1490
+ # Push command for undo support (change already made, so just push)
1491
+ cmd = EditLabelCommand(self, shape, old_label, label)
1492
+ self.undo_stack.push(cmd)
1493
+
1494
+ self.set_dirty()
1495
+ else: # User probably changed item visibility
1496
+ self.canvas.set_shape_visible(shape, item.checkState() == Qt.Checked)
1497
+
1498
+ # Callback functions:
1499
+ def new_shape(self):
1500
+ """Pop-up and give focus to the label editor.
1501
+
1502
+ position MUST be in global coordinates.
1503
+ """
1504
+ if not self.use_default_label_checkbox.isChecked():
1505
+ if len(self.label_hist) > 0:
1506
+ self.label_dialog = LabelDialog(
1507
+ parent=self, list_item=self.label_hist)
1508
+
1509
+ # Sync single class mode from PR#106
1510
+ if self.single_class_mode.isChecked() and self.lastLabel:
1511
+ text = self.lastLabel
1512
+ else:
1513
+ text = self.label_dialog.pop_up(text=self.prev_label_text)
1514
+ self.lastLabel = text
1515
+ else:
1516
+ text = self.default_label
1517
+
1518
+ # Add Chris
1519
+ self.diffc_button.setChecked(False)
1520
+ if text is not None:
1521
+ self.prev_label_text = text
1522
+ generate_color = generate_color_by_text(text)
1523
+ shape = self.canvas.set_last_label(text, generate_color, generate_color)
1524
+ self.add_label(shape)
1525
+
1526
+ # Push command for undo support (shape already created, so just push)
1527
+ cmd = CreateShapeCommand(self, shape)
1528
+ self.undo_stack.push(cmd)
1529
+
1530
+ if self.beginner(): # Switch to edit mode.
1531
+ self.canvas.set_editing(True)
1532
+ self.actions.create.setEnabled(True)
1533
+ else:
1534
+ self.actions.editMode.setEnabled(True)
1535
+ self.set_dirty()
1536
+
1537
+ if text not in self.label_hist:
1538
+ self.label_hist.append(text)
1539
+ else:
1540
+ # self.canvas.undoLastLine()
1541
+ self.canvas.reset_all_lines()
1542
+
1543
+ def scroll_request(self, delta, orientation):
1544
+ units = - delta / (8 * 15)
1545
+ bar = self.scroll_bars[orientation]
1546
+ bar.setValue(int(bar.value() + bar.singleStep() * units))
1547
+
1548
+ def set_zoom(self, value):
1549
+ self.actions.fitWidth.setChecked(False)
1550
+ self.actions.fitWindow.setChecked(False)
1551
+ self.zoom_mode = self.MANUAL_ZOOM
1552
+ # Arithmetic on scaling factor often results in float
1553
+ # Convert to int to avoid type errors
1554
+ self.zoom_widget.setValue(int(value))
1555
+
1556
+ def add_zoom(self, increment=10):
1557
+ self.set_zoom(self.zoom_widget.value() + increment)
1558
+
1559
+ def zoom_request(self, delta):
1560
+ # get the current scrollbar positions
1561
+ # calculate the percentages ~ coordinates
1562
+ h_bar = self.scroll_bars[Qt.Horizontal]
1563
+ v_bar = self.scroll_bars[Qt.Vertical]
1564
+
1565
+ # get the current maximum, to know the difference after zooming
1566
+ h_bar_max = h_bar.maximum()
1567
+ v_bar_max = v_bar.maximum()
1568
+
1569
+ # get the cursor position and canvas size
1570
+ # calculate the desired movement from 0 to 1
1571
+ # where 0 = move left
1572
+ # 1 = move right
1573
+ # up and down analogous
1574
+ cursor = QCursor()
1575
+ pos = cursor.pos()
1576
+ relative_pos = QWidget.mapFromGlobal(self, pos)
1577
+
1578
+ cursor_x = relative_pos.x()
1579
+ cursor_y = relative_pos.y()
1580
+
1581
+ w = self.scroll_area.width()
1582
+ h = self.scroll_area.height()
1583
+
1584
+ # the scaling from 0 to 1 has some padding
1585
+ # you don't have to hit the very leftmost pixel for a maximum-left movement
1586
+ margin = 0.1
1587
+ move_x = (cursor_x - margin * w) / (w - 2 * margin * w)
1588
+ move_y = (cursor_y - margin * h) / (h - 2 * margin * h)
1589
+
1590
+ # clamp the values from 0 to 1
1591
+ move_x = min(max(move_x, 0), 1)
1592
+ move_y = min(max(move_y, 0), 1)
1593
+
1594
+ # zoom in
1595
+ units = delta // (8 * 15)
1596
+ scale = 10
1597
+ self.add_zoom(scale * units)
1598
+
1599
+ # get the difference in scrollbar values
1600
+ # this is how far we can move
1601
+ d_h_bar_max = h_bar.maximum() - h_bar_max
1602
+ d_v_bar_max = v_bar.maximum() - v_bar_max
1603
+
1604
+ # get the new scrollbar values
1605
+ new_h_bar_value = int(h_bar.value() + move_x * d_h_bar_max)
1606
+ new_v_bar_value = int(v_bar.value() + move_y * d_v_bar_max)
1607
+
1608
+ h_bar.setValue(new_h_bar_value)
1609
+ v_bar.setValue(new_v_bar_value)
1610
+
1611
+ def light_request(self, delta):
1612
+ self.add_light(5*delta // (8 * 15))
1613
+
1614
+ def set_fit_window(self, value=True):
1615
+ if value:
1616
+ self.actions.fitWidth.setChecked(False)
1617
+ self.zoom_mode = self.FIT_WINDOW if value else self.MANUAL_ZOOM
1618
+ self.adjust_scale()
1619
+
1620
+ def set_fit_width(self, value=True):
1621
+ if value:
1622
+ self.actions.fitWindow.setChecked(False)
1623
+ self.zoom_mode = self.FIT_WIDTH if value else self.MANUAL_ZOOM
1624
+ self.adjust_scale()
1625
+
1626
+ def set_light(self, value):
1627
+ self.actions.lightOrg.setChecked(int(value) == 50)
1628
+ # Arithmetic on scaling factor often results in float
1629
+ # Convert to int to avoid type errors
1630
+ self.light_widget.setValue(int(value))
1631
+
1632
+ def add_light(self, increment=10):
1633
+ self.set_light(self.light_widget.value() + increment)
1634
+
1635
+ def toggle_polygons(self, value):
1636
+ for item, shape in self.items_to_shapes.items():
1637
+ item.setCheckState(Qt.Checked if value else Qt.Unchecked)
1638
+
1639
+ def load_file(self, file_path=None):
1640
+ """Load the specified file, or the last opened file if None."""
1641
+ self.reset_state()
1642
+ self.canvas.setEnabled(False)
1643
+ if file_path is None:
1644
+ file_path = self.settings.get(SETTING_FILENAME)
1645
+ # Make sure that filePath is a regular python string, rather than QString
1646
+ file_path = ustr(file_path)
1647
+
1648
+ # Fix bug: An index error after select a directory when open a new file.
1649
+ unicode_file_path = ustr(file_path)
1650
+ unicode_file_path = os.path.abspath(unicode_file_path)
1651
+ # Tzutalin 20160906 : Add file list and dock to move faster
1652
+ # Highlight the file item
1653
+ if unicode_file_path and self.file_list_widget.count() > 0:
1654
+ if unicode_file_path in self._path_to_idx:
1655
+ index = self._path_to_idx[unicode_file_path]
1656
+ file_widget_item = self.file_list_widget.item(index)
1657
+ file_widget_item.setSelected(True)
1658
+ # Sync gallery selection
1659
+ self.gallery_widget.select_image(unicode_file_path)
1660
+ else:
1661
+ self.file_list_widget.clear()
1662
+ self.m_img_list.clear()
1663
+
1664
+ if unicode_file_path and os.path.exists(unicode_file_path):
1665
+ if LabelFile.is_label_file(unicode_file_path):
1666
+ try:
1667
+ self.label_file = LabelFile(unicode_file_path)
1668
+ except LabelFileError as e:
1669
+ self.error_message(u'Error opening file',
1670
+ (u"<p><b>%s</b></p>"
1671
+ u"<p>Make sure <i>%s</i> is a valid label file.")
1672
+ % (e, unicode_file_path))
1673
+ self.status("Error reading %s" % unicode_file_path)
1674
+
1675
+ return False
1676
+ self.image_data = self.label_file.image_data
1677
+ self.line_color = QColor(*self.label_file.lineColor)
1678
+ self.fill_color = QColor(*self.label_file.fillColor)
1679
+ self.canvas.verified = self.label_file.verified
1680
+ else:
1681
+ # Load image with memory-efficient downsampling for large images
1682
+ self.label_file = None
1683
+ self.canvas.verified = False
1684
+
1685
+ # Use QImageReader for memory-efficient loading
1686
+ reader = QImageReader(unicode_file_path)
1687
+ reader.setAutoTransform(True)
1688
+ original_size = reader.size()
1689
+
1690
+ if not original_size.isValid():
1691
+ self.error_message(u'Error opening file',
1692
+ u"<p>Make sure <i>%s</i> is a valid image file." % unicode_file_path)
1693
+ self.status("Error reading %s" % unicode_file_path)
1694
+ return False
1695
+
1696
+ # Downsample if larger than 2048px on either dimension (Issue #31)
1697
+ MAX_DISPLAY_DIM = 2048
1698
+ if original_size.width() > MAX_DISPLAY_DIM or original_size.height() > MAX_DISPLAY_DIM:
1699
+ scaled_size = original_size.scaled(MAX_DISPLAY_DIM, MAX_DISPLAY_DIM, Qt.KeepAspectRatio)
1700
+ reader.setScaledSize(scaled_size)
1701
+ self._image_scale_factor = scaled_size.width() / original_size.width()
1702
+ else:
1703
+ self._image_scale_factor = 1.0
1704
+
1705
+ self._original_image_size = original_size
1706
+ image = reader.read()
1707
+
1708
+ if image.isNull():
1709
+ self.error_message(u'Error opening file',
1710
+ u"<p>Make sure <i>%s</i> is a valid image file." % unicode_file_path)
1711
+ self.status("Error reading %s" % unicode_file_path)
1712
+ return False
1713
+
1714
+ # Don't store full image data - saves memory
1715
+ self.image_data = None
1716
+
1717
+ self.status("Loaded %s" % os.path.basename(unicode_file_path))
1718
+ self.image = image
1719
+ self.file_path = unicode_file_path
1720
+ self.canvas.load_pixmap(QPixmap.fromImage(image))
1721
+ if self.label_file:
1722
+ self.load_labels(self.label_file.shapes)
1723
+ self.set_clean()
1724
+ self.canvas.setEnabled(True)
1725
+ self.adjust_scale(initial=True)
1726
+ self.paint_canvas()
1727
+ self.add_recent_file(self.file_path)
1728
+ self.toggle_actions(True)
1729
+ self.show_bounding_box_from_annotation_file(self.file_path)
1730
+
1731
+ counter = self.counter_str()
1732
+ self.setWindowTitle(__appname__ + ' ' + file_path + ' ' + counter)
1733
+
1734
+ # Update status bar widgets
1735
+ self.update_status_bar()
1736
+ self.update_save_status(saved=True)
1737
+
1738
+ # Default : select last item if there is at least one item
1739
+ if self.label_list.count():
1740
+ self.label_list.setCurrentItem(self.label_list.item(self.label_list.count() - 1))
1741
+ self.label_list.item(self.label_list.count() - 1).setSelected(True)
1742
+
1743
+ self.canvas.setFocus(True)
1744
+ return True
1745
+ return False
1746
+
1747
+ def counter_str(self):
1748
+ """
1749
+ Converts image counter to string representation.
1750
+ """
1751
+ return '[{} / {}]'.format(self.cur_img_idx + 1, self.img_count)
1752
+
1753
+ def show_bounding_box_from_annotation_file(self, file_path):
1754
+ if self.default_save_dir is not None:
1755
+ basename = os.path.basename(os.path.splitext(file_path)[0])
1756
+ xml_path = os.path.join(self.default_save_dir, basename + XML_EXT)
1757
+ txt_path = os.path.join(self.default_save_dir, basename + TXT_EXT)
1758
+ json_path = os.path.join(self.default_save_dir, basename + JSON_EXT)
1759
+
1760
+ """Annotation file priority:
1761
+ PascalXML > YOLO
1762
+ """
1763
+ if os.path.isfile(xml_path):
1764
+ self.load_pascal_xml_by_filename(xml_path)
1765
+ elif os.path.isfile(txt_path):
1766
+ self.load_yolo_txt_by_filename(txt_path)
1767
+ elif os.path.isfile(json_path):
1768
+ self.load_create_ml_json_by_filename(json_path, file_path)
1769
+
1770
+ else:
1771
+ xml_path = os.path.splitext(file_path)[0] + XML_EXT
1772
+ txt_path = os.path.splitext(file_path)[0] + TXT_EXT
1773
+ json_path = os.path.splitext(file_path)[0] + JSON_EXT
1774
+
1775
+ if os.path.isfile(xml_path):
1776
+ self.load_pascal_xml_by_filename(xml_path)
1777
+ elif os.path.isfile(txt_path):
1778
+ self.load_yolo_txt_by_filename(txt_path)
1779
+ elif os.path.isfile(json_path):
1780
+ self.load_create_ml_json_by_filename(json_path, file_path)
1781
+
1782
+
1783
+ def resizeEvent(self, event):
1784
+ if self.canvas and not self.image.isNull()\
1785
+ and self.zoom_mode != self.MANUAL_ZOOM:
1786
+ self.adjust_scale()
1787
+ super(MainWindow, self).resizeEvent(event)
1788
+
1789
+ def paint_canvas(self):
1790
+ assert not self.image.isNull(), "cannot paint null image"
1791
+ self.canvas.scale = 0.01 * self.zoom_widget.value()
1792
+ self.canvas.overlay_color = self.light_widget.color()
1793
+ self.canvas.label_font_size = int(0.02 * max(self.image.width(), self.image.height()))
1794
+ self.canvas.adjustSize()
1795
+ self.canvas.update()
1796
+
1797
+ def adjust_scale(self, initial=False):
1798
+ value = self.scalers[self.FIT_WINDOW if initial else self.zoom_mode]()
1799
+ self.zoom_widget.setValue(int(100 * value))
1800
+
1801
+ def scale_fit_window(self):
1802
+ """Figure out the size of the pixmap in order to fit the main widget."""
1803
+ e = 2.0 # So that no scrollbars are generated.
1804
+ w1 = self.centralWidget().width() - e
1805
+ h1 = self.centralWidget().height() - e
1806
+ a1 = w1 / h1
1807
+ # Calculate a new scale value based on the pixmap's aspect ratio.
1808
+ w2 = self.canvas.pixmap.width() - 0.0
1809
+ h2 = self.canvas.pixmap.height() - 0.0
1810
+ a2 = w2 / h2
1811
+ return w1 / w2 if a2 >= a1 else h1 / h2
1812
+
1813
+ def scale_fit_width(self):
1814
+ # The epsilon does not seem to work too well here.
1815
+ w = self.centralWidget().width() - 2.0
1816
+ return w / self.canvas.pixmap.width()
1817
+
1818
+ def closeEvent(self, event):
1819
+ if not self.may_continue():
1820
+ event.ignore()
1821
+ settings = self.settings
1822
+ # If it loads images from dir, don't load it at the beginning
1823
+ if self.dir_name is None:
1824
+ settings[SETTING_FILENAME] = self.file_path if self.file_path else ''
1825
+ else:
1826
+ settings[SETTING_FILENAME] = ''
1827
+
1828
+ settings[SETTING_WIN_SIZE] = self.size()
1829
+ settings[SETTING_WIN_POSE] = self.pos()
1830
+ settings[SETTING_WIN_STATE] = self.saveState()
1831
+ settings[SETTING_LINE_COLOR] = self.line_color
1832
+ settings[SETTING_FILL_COLOR] = self.fill_color
1833
+ settings[SETTING_RECENT_FILES] = self.recent_files
1834
+ settings[SETTING_ADVANCE_MODE] = not self._beginner
1835
+ settings[SETTING_GALLERY_MODE] = self.gallery_mode_enabled
1836
+ if self.default_save_dir and os.path.exists(self.default_save_dir):
1837
+ settings[SETTING_SAVE_DIR] = ustr(self.default_save_dir)
1838
+ else:
1839
+ settings[SETTING_SAVE_DIR] = ''
1840
+
1841
+ if self.last_open_dir and os.path.exists(self.last_open_dir):
1842
+ settings[SETTING_LAST_OPEN_DIR] = self.last_open_dir
1843
+ else:
1844
+ settings[SETTING_LAST_OPEN_DIR] = ''
1845
+
1846
+ settings[SETTING_AUTO_SAVE] = self.auto_saving.isChecked()
1847
+ settings[SETTING_AUTO_SAVE_ENABLED] = self.auto_save_enabled.isChecked()
1848
+ settings[SETTING_AUTO_SAVE_INTERVAL] = self._get_current_auto_save_interval()
1849
+ settings[SETTING_SINGLE_CLASS] = self.single_class_mode.isChecked()
1850
+ settings[SETTING_PAINT_LABEL] = self.display_label_option.isChecked()
1851
+ settings[SETTING_DRAW_SQUARE] = self.draw_squares_option.isChecked()
1852
+ settings[SETTING_LABEL_FILE_FORMAT] = self.label_file_format
1853
+ settings[SETTING_TOOLBAR_EXPANDED] = self.tools.is_expanded()
1854
+ settings.save()
1855
+
1856
+ def load_recent(self, filename):
1857
+ if self.may_continue():
1858
+ self.load_file(filename)
1859
+
1860
+ def scan_all_images(self, folder_path):
1861
+ extensions = ['.%s' % fmt.data().decode("ascii").lower() for fmt in QImageReader.supportedImageFormats()]
1862
+ images = []
1863
+
1864
+ for root, dirs, files in os.walk(folder_path):
1865
+ for file in files:
1866
+ if file.lower().endswith(tuple(extensions)):
1867
+ relative_path = os.path.join(root, file)
1868
+ path = ustr(os.path.abspath(relative_path))
1869
+ images.append(path)
1870
+ natural_sort(images, key=lambda x: x.lower())
1871
+ return images
1872
+
1873
+ def change_save_dir_dialog(self, _value=False):
1874
+ if self.default_save_dir is not None:
1875
+ path = ustr(self.default_save_dir)
1876
+ else:
1877
+ path = '.'
1878
+
1879
+ dir_path = ustr(QFileDialog.getExistingDirectory(self,
1880
+ '%s - Save annotations to the directory' % __appname__, path, QFileDialog.ShowDirsOnly
1881
+ | QFileDialog.DontResolveSymlinks))
1882
+
1883
+ if dir_path is not None and len(dir_path) > 1:
1884
+ self.default_save_dir = dir_path
1885
+ # Clear status cache since annotation directory changed
1886
+ self._invalidate_status_cache()
1887
+ # Update gallery to reload thumbnails with annotations from new dir
1888
+ self.gallery_widget.set_save_dir(self.default_save_dir)
1889
+
1890
+ self.show_bounding_box_from_annotation_file(self.file_path)
1891
+
1892
+ self.statusBar().showMessage('%s . Annotation will be saved to %s' %
1893
+ ('Change saved folder', self.default_save_dir))
1894
+ self.statusBar().show()
1895
+
1896
+
1897
+ def open_annotation_dialog(self, _value=False):
1898
+ if self.file_path is None:
1899
+ self.statusBar().showMessage('Please select image first')
1900
+ self.statusBar().show()
1901
+ return
1902
+
1903
+ path = os.path.dirname(ustr(self.file_path))\
1904
+ if self.file_path else '.'
1905
+ if self.label_file_format == LabelFileFormat.PASCAL_VOC:
1906
+ filters = "Open Annotation XML file (%s)" % ' '.join(['*.xml'])
1907
+ filename = ustr(QFileDialog.getOpenFileName(self, '%s - Choose a xml file' % __appname__, path, filters))
1908
+ if filename:
1909
+ if isinstance(filename, (tuple, list)):
1910
+ filename = filename[0]
1911
+ self.load_pascal_xml_by_filename(filename)
1912
+
1913
+ elif self.label_file_format == LabelFileFormat.CREATE_ML:
1914
+
1915
+ filters = "Open Annotation JSON file (%s)" % ' '.join(['*.json'])
1916
+ filename = ustr(QFileDialog.getOpenFileName(self, '%s - Choose a json file' % __appname__, path, filters))
1917
+ if filename:
1918
+ if isinstance(filename, (tuple, list)):
1919
+ filename = filename[0]
1920
+
1921
+ self.load_create_ml_json_by_filename(filename, self.file_path)
1922
+
1923
+
1924
+ def open_dir_dialog(self, _value=False, dir_path=None, silent=False):
1925
+ if not self.may_continue():
1926
+ return
1927
+
1928
+ default_open_dir_path = dir_path if dir_path else '.'
1929
+ if self.last_open_dir and os.path.exists(self.last_open_dir):
1930
+ default_open_dir_path = self.last_open_dir
1931
+ else:
1932
+ default_open_dir_path = os.path.dirname(self.file_path) if self.file_path else '.'
1933
+ if silent != True:
1934
+ target_dir_path = ustr(QFileDialog.getExistingDirectory(self,
1935
+ '%s - Open Directory' % __appname__, default_open_dir_path,
1936
+ QFileDialog.ShowDirsOnly | QFileDialog.DontResolveSymlinks))
1937
+ else:
1938
+ target_dir_path = ustr(default_open_dir_path)
1939
+ self.last_open_dir = target_dir_path
1940
+ self.import_dir_images(target_dir_path)
1941
+ # Only set default_save_dir if not already set (e.g., from command line)
1942
+ if self.default_save_dir is None:
1943
+ self.default_save_dir = target_dir_path
1944
+ if self.file_path:
1945
+ self.show_bounding_box_from_annotation_file(file_path=self.file_path)
1946
+
1947
+ def import_dir_images(self, dir_path):
1948
+ if not self.may_continue() or not dir_path:
1949
+ return
1950
+
1951
+ self.last_open_dir = dir_path
1952
+ self.dir_name = dir_path
1953
+ self.file_path = None
1954
+ self.file_list_widget.clear()
1955
+
1956
+ # Show progress dialog for scanning
1957
+ progress = QProgressDialog("Scanning directory...", "Cancel", 0, 0, self)
1958
+ progress.setWindowTitle("Loading Images")
1959
+ progress.setWindowModality(Qt.WindowModal)
1960
+ progress.setMinimumDuration(500) # Only show if operation takes > 500ms
1961
+ progress.setValue(0)
1962
+ QApplication.processEvents()
1963
+
1964
+ self.m_img_list = self.scan_all_images(dir_path)
1965
+ self._path_to_idx = {path: idx for idx, path in enumerate(self.m_img_list)}
1966
+ self._annotation_status_cache.clear() # Clear cache for new directory
1967
+ self.img_count = len(self.m_img_list)
1968
+
1969
+ if progress.wasCanceled():
1970
+ progress.close()
1971
+ return
1972
+
1973
+ # Update progress for file list population
1974
+ if self.img_count > 100:
1975
+ progress.setLabelText(f"Loading {self.img_count} images...")
1976
+ progress.setMaximum(self.img_count)
1977
+
1978
+ # Populate file list widget
1979
+ for i, imgPath in enumerate(self.m_img_list):
1980
+ item = QListWidgetItem(imgPath)
1981
+ self.file_list_widget.addItem(item)
1982
+ if self.img_count > 100 and i % 50 == 0:
1983
+ progress.setValue(i)
1984
+ QApplication.processEvents()
1985
+ if progress.wasCanceled():
1986
+ progress.close()
1987
+ return
1988
+
1989
+ progress.setValue(self.img_count)
1990
+
1991
+ # Populate gallery widget with annotation directory
1992
+ self.gallery_widget.set_save_dir(self.default_save_dir)
1993
+ self.gallery_widget.set_image_list(self.m_img_list)
1994
+ self._refresh_gallery_statuses()
1995
+
1996
+ # Update full-screen gallery if active
1997
+ if hasattr(self, 'full_gallery') and self.full_gallery:
1998
+ self.full_gallery.set_save_dir(self.default_save_dir)
1999
+ self.full_gallery.set_image_list(self.m_img_list)
2000
+ self._refresh_full_gallery_statuses()
2001
+
2002
+ progress.close()
2003
+
2004
+ # Update image count in status bar
2005
+ self.update_image_count()
2006
+ self.open_next_image()
2007
+
2008
+ def verify_image(self, _value=False):
2009
+ # Proceeding next image without dialog if having any label
2010
+ if self.file_path is not None:
2011
+ try:
2012
+ self.label_file.toggle_verify()
2013
+ except AttributeError:
2014
+ # If the labelling file does not exist yet, create if and
2015
+ # re-save it with the verified attribute.
2016
+ self.save_file()
2017
+ if self.label_file is not None:
2018
+ self.label_file.toggle_verify()
2019
+ else:
2020
+ return
2021
+
2022
+ self.canvas.verified = self.label_file.verified
2023
+ self.paint_canvas()
2024
+ self.save_file()
2025
+ # Update gallery status after verify
2026
+ self._update_current_image_gallery_status()
2027
+
2028
+ def open_prev_image(self, _value=False):
2029
+ # Proceeding prev image without dialog if having any label
2030
+ if self.auto_saving.isChecked():
2031
+ if self.default_save_dir is not None:
2032
+ if self.dirty is True:
2033
+ self.save_file()
2034
+ else:
2035
+ self.change_save_dir_dialog()
2036
+ return
2037
+
2038
+ if not self.may_continue():
2039
+ return
2040
+
2041
+ if self.img_count <= 0:
2042
+ return
2043
+
2044
+ if self.file_path is None:
2045
+ return
2046
+
2047
+ if self.cur_img_idx - 1 >= 0:
2048
+ self.cur_img_idx -= 1
2049
+ filename = self.m_img_list[self.cur_img_idx]
2050
+ if filename:
2051
+ self.load_file(filename)
2052
+
2053
+ def open_next_image(self, _value=False):
2054
+ # Proceeding next image without dialog if having any label
2055
+ if self.auto_saving.isChecked():
2056
+ if self.default_save_dir is not None:
2057
+ if self.dirty is True:
2058
+ self.save_file()
2059
+ else:
2060
+ self.change_save_dir_dialog()
2061
+ return
2062
+
2063
+ if not self.may_continue():
2064
+ return
2065
+
2066
+ if self.img_count <= 0:
2067
+ return
2068
+
2069
+ if not self.m_img_list:
2070
+ return
2071
+
2072
+ filename = None
2073
+ if self.file_path is None:
2074
+ filename = self.m_img_list[0]
2075
+ self.cur_img_idx = 0
2076
+ else:
2077
+ if self.cur_img_idx + 1 < self.img_count:
2078
+ self.cur_img_idx += 1
2079
+ filename = self.m_img_list[self.cur_img_idx]
2080
+
2081
+ if filename:
2082
+ self.load_file(filename)
2083
+
2084
+ def open_file(self, _value=False):
2085
+ if not self.may_continue():
2086
+ return
2087
+ path = os.path.dirname(ustr(self.file_path)) if self.file_path else '.'
2088
+ formats = ['*.%s' % fmt.data().decode("ascii").lower() for fmt in QImageReader.supportedImageFormats()]
2089
+ filters = "Image & Label files (%s)" % ' '.join(formats + ['*%s' % LabelFile.suffix])
2090
+ filename,_ = QFileDialog.getOpenFileName(self, '%s - Choose Image or Label file' % __appname__, path, filters)
2091
+ if filename:
2092
+ if isinstance(filename, (tuple, list)):
2093
+ filename = filename[0]
2094
+ self.cur_img_idx = 0
2095
+ self.img_count = 1
2096
+ self.load_file(filename)
2097
+
2098
+ def save_file(self, _value=False):
2099
+ if self.default_save_dir is not None and len(ustr(self.default_save_dir)):
2100
+ if self.file_path:
2101
+ image_file_name = os.path.basename(self.file_path)
2102
+ saved_file_name = os.path.splitext(image_file_name)[0]
2103
+ saved_path = os.path.join(ustr(self.default_save_dir), saved_file_name)
2104
+ self._save_file(saved_path)
2105
+ else:
2106
+ image_file_dir = os.path.dirname(self.file_path)
2107
+ image_file_name = os.path.basename(self.file_path)
2108
+ saved_file_name = os.path.splitext(image_file_name)[0]
2109
+ saved_path = os.path.join(image_file_dir, saved_file_name)
2110
+ self._save_file(saved_path if self.label_file
2111
+ else self.save_file_dialog(remove_ext=False))
2112
+
2113
+ def save_file_as(self, _value=False):
2114
+ assert not self.image.isNull(), "cannot save empty image"
2115
+ self._save_file(self.save_file_dialog())
2116
+
2117
+ def save_file_dialog(self, remove_ext=True):
2118
+ caption = '%s - Choose File' % __appname__
2119
+ filters = 'File (*%s)' % LabelFile.suffix
2120
+ open_dialog_path = self.current_path()
2121
+ dlg = QFileDialog(self, caption, open_dialog_path, filters)
2122
+ dlg.setDefaultSuffix(LabelFile.suffix[1:])
2123
+ dlg.setAcceptMode(QFileDialog.AcceptSave)
2124
+ filename_without_extension = os.path.splitext(self.file_path)[0]
2125
+ dlg.selectFile(filename_without_extension)
2126
+ dlg.setOption(QFileDialog.DontUseNativeDialog, False)
2127
+ if dlg.exec_():
2128
+ full_file_path = ustr(dlg.selectedFiles()[0])
2129
+ if remove_ext:
2130
+ return os.path.splitext(full_file_path)[0] # Return file path without the extension.
2131
+ else:
2132
+ return full_file_path
2133
+ return ''
2134
+
2135
+ def _save_file(self, annotation_file_path):
2136
+ if annotation_file_path and self.save_labels(annotation_file_path):
2137
+ self.set_clean()
2138
+ self.statusBar().showMessage('Saved to %s' % annotation_file_path)
2139
+ self.statusBar().show()
2140
+ # Update gallery status after save
2141
+ self._update_current_image_gallery_status()
2142
+
2143
+ def close_file(self, _value=False):
2144
+ if not self.may_continue():
2145
+ return
2146
+ self.reset_state()
2147
+ self.set_clean()
2148
+ self.toggle_actions(False)
2149
+ self.canvas.setEnabled(False)
2150
+ self.actions.saveAs.setEnabled(False)
2151
+
2152
+ def delete_image(self):
2153
+ delete_path = self.file_path
2154
+ if delete_path is not None:
2155
+ idx = self.cur_img_idx
2156
+ if os.path.exists(delete_path):
2157
+ os.remove(delete_path)
2158
+ self.import_dir_images(self.last_open_dir)
2159
+ if self.img_count > 0:
2160
+ self.cur_img_idx = min(idx, self.img_count - 1)
2161
+ filename = self.m_img_list[self.cur_img_idx]
2162
+ self.load_file(filename)
2163
+ else:
2164
+ self.close_file()
2165
+
2166
+ def reset_all(self):
2167
+ self.settings.reset()
2168
+ self.close()
2169
+ process = QProcess()
2170
+ process.startDetached(os.path.abspath(__file__))
2171
+
2172
+ def may_continue(self):
2173
+ if not self.dirty:
2174
+ return True
2175
+ else:
2176
+ discard_changes = self.discard_changes_dialog()
2177
+ if discard_changes == QMessageBox.No:
2178
+ return True
2179
+ elif discard_changes == QMessageBox.Yes:
2180
+ self.save_file()
2181
+ return True
2182
+ else:
2183
+ return False
2184
+
2185
+ def discard_changes_dialog(self):
2186
+ yes, no, cancel = QMessageBox.Yes, QMessageBox.No, QMessageBox.Cancel
2187
+ msg = u'You have unsaved changes, would you like to save them and proceed?\nClick "No" to undo all changes.'
2188
+ return QMessageBox.warning(self, u'Attention', msg, yes | no | cancel)
2189
+
2190
+ def error_message(self, title, message):
2191
+ return QMessageBox.critical(self, title,
2192
+ '<p><b>%s</b></p>%s' % (title, message))
2193
+
2194
+ def current_path(self):
2195
+ return os.path.dirname(self.file_path) if self.file_path else '.'
2196
+
2197
+ def choose_color1(self):
2198
+ color = self.color_dialog.getColor(self.line_color, u'Choose line color',
2199
+ default=DEFAULT_LINE_COLOR)
2200
+ if color:
2201
+ self.line_color = color
2202
+ Shape.line_color = color
2203
+ self.canvas.set_drawing_color(color)
2204
+ self.canvas.update()
2205
+ self.set_dirty()
2206
+
2207
+ def delete_selected_shape(self):
2208
+ """Delete the currently selected shape with undo support."""
2209
+ if self.canvas.selected_shape is None:
2210
+ return
2211
+ shape = self.canvas.selected_shape
2212
+ index = self.canvas.shapes.index(shape) if shape in self.canvas.shapes else None
2213
+
2214
+ # Create and push command (command handles the actual deletion)
2215
+ cmd = DeleteShapeCommand(self, shape, index)
2216
+ cmd.execute()
2217
+ self.undo_stack.push(cmd)
2218
+ self.set_dirty()
2219
+
2220
+ if self.no_shapes():
2221
+ for action in self.actions.onShapesPresent:
2222
+ action.setEnabled(False)
2223
+
2224
+ def undo_action(self):
2225
+ """Undo the last action."""
2226
+ if self.undo_stack.can_undo():
2227
+ self.undo_stack.undo()
2228
+ self.set_dirty()
2229
+ self.canvas.update()
2230
+
2231
+ def redo_action(self):
2232
+ """Redo the last undone action."""
2233
+ if self.undo_stack.can_redo():
2234
+ self.undo_stack.redo()
2235
+ self.set_dirty()
2236
+ self.canvas.update()
2237
+
2238
+ def update_undo_redo_actions(self):
2239
+ """Update the enabled state of undo/redo actions."""
2240
+ self.actions.undo.setEnabled(self.undo_stack.can_undo())
2241
+ self.actions.redo.setEnabled(self.undo_stack.can_redo())
2242
+
2243
+ # Update tooltips with descriptions
2244
+ if self.undo_stack.can_undo():
2245
+ desc = self.undo_stack.get_undo_description()
2246
+ self.actions.undo.setToolTip(f"Undo: {desc}")
2247
+ else:
2248
+ self.actions.undo.setToolTip("Undo")
2249
+
2250
+ if self.undo_stack.can_redo():
2251
+ desc = self.undo_stack.get_redo_description()
2252
+ self.actions.redo.setToolTip(f"Redo: {desc}")
2253
+ else:
2254
+ self.actions.redo.setToolTip("Redo")
2255
+
2256
+ def choose_shape_line_color(self):
2257
+ color = self.color_dialog.getColor(self.line_color, u'Choose Line Color',
2258
+ default=DEFAULT_LINE_COLOR)
2259
+ if color:
2260
+ self.canvas.selected_shape.line_color = color
2261
+ self.canvas.update()
2262
+ self.set_dirty()
2263
+
2264
+ def choose_shape_fill_color(self):
2265
+ color = self.color_dialog.getColor(self.fill_color, u'Choose Fill Color',
2266
+ default=DEFAULT_FILL_COLOR)
2267
+ if color:
2268
+ self.canvas.selected_shape.fill_color = color
2269
+ self.canvas.update()
2270
+ self.set_dirty()
2271
+
2272
+ def copy_shape(self):
2273
+ if self.canvas.selected_shape is None:
2274
+ # True if one accidentally touches the left mouse button before releasing
2275
+ return
2276
+ self.canvas.end_move(copy=True)
2277
+ self.add_label(self.canvas.selected_shape)
2278
+ self.set_dirty()
2279
+
2280
+ def move_shape(self):
2281
+ self.canvas.end_move(copy=False)
2282
+ self.set_dirty()
2283
+
2284
+ def load_predefined_classes(self, predef_classes_file):
2285
+ if os.path.exists(predef_classes_file) is True:
2286
+ with codecs.open(predef_classes_file, 'r', 'utf8') as f:
2287
+ for line in f:
2288
+ line = line.strip()
2289
+ if self.label_hist is None:
2290
+ self.label_hist = [line]
2291
+ else:
2292
+ self.label_hist.append(line)
2293
+
2294
+ def load_pascal_xml_by_filename(self, xml_path):
2295
+ if self.file_path is None:
2296
+ return
2297
+ if os.path.isfile(xml_path) is False:
2298
+ return
2299
+
2300
+ self.set_format(FORMAT_PASCALVOC)
2301
+
2302
+ t_voc_parse_reader = PascalVocReader(xml_path)
2303
+ shapes = t_voc_parse_reader.get_shapes()
2304
+ self.load_labels(shapes)
2305
+ self.canvas.verified = t_voc_parse_reader.verified
2306
+
2307
+ def load_yolo_txt_by_filename(self, txt_path):
2308
+ if self.file_path is None:
2309
+ return
2310
+ if os.path.isfile(txt_path) is False:
2311
+ return
2312
+
2313
+ self.set_format(FORMAT_YOLO)
2314
+ # Use original image size for YOLO coordinate conversion (Issue #31)
2315
+ # YOLO stores normalized coords, so we need original dimensions
2316
+ if hasattr(self, '_original_image_size') and self._original_image_size is not None:
2317
+ # Create a mock image object with original dimensions
2318
+ class MockImage:
2319
+ def __init__(self, size, grayscale=False):
2320
+ self._size = size
2321
+ self._grayscale = grayscale
2322
+ def width(self):
2323
+ return self._size.width()
2324
+ def height(self):
2325
+ return self._size.height()
2326
+ def isGrayscale(self):
2327
+ return self._grayscale
2328
+ mock_img = MockImage(self._original_image_size, self.image.isGrayscale())
2329
+ t_yolo_parse_reader = YoloReader(txt_path, mock_img)
2330
+ else:
2331
+ t_yolo_parse_reader = YoloReader(txt_path, self.image)
2332
+ shapes = t_yolo_parse_reader.get_shapes()
2333
+ self.load_labels(shapes)
2334
+ self.canvas.verified = t_yolo_parse_reader.verified
2335
+
2336
+ def load_create_ml_json_by_filename(self, json_path, file_path):
2337
+ if self.file_path is None:
2338
+ return
2339
+ if os.path.isfile(json_path) is False:
2340
+ return
2341
+
2342
+ self.set_format(FORMAT_CREATEML)
2343
+
2344
+ create_ml_parse_reader = CreateMLReader(json_path, file_path)
2345
+ shapes = create_ml_parse_reader.get_shapes()
2346
+ self.load_labels(shapes)
2347
+ self.canvas.verified = create_ml_parse_reader.verified
2348
+
2349
+ def copy_previous_bounding_boxes(self):
2350
+ current_index = self._path_to_idx.get(self.file_path, 0)
2351
+ if current_index - 1 >= 0:
2352
+ prev_file_path = self.m_img_list[current_index - 1]
2353
+ self.show_bounding_box_from_annotation_file(prev_file_path)
2354
+ self.save_file()
2355
+
2356
+ def toggle_paint_labels_option(self):
2357
+ for shape in self.canvas.shapes:
2358
+ shape.paint_label = self.display_label_option.isChecked()
2359
+
2360
+ def toggle_draw_square(self):
2361
+ self.canvas.set_drawing_shape_to_square(self.draw_squares_option.isChecked())
2362
+
2363
+ def change_icon_size(self):
2364
+ """Change toolbar icon size based on user selection."""
2365
+ action = self.sender()
2366
+ if action:
2367
+ size = action.data()
2368
+ self.settings[SETTING_ICON_SIZE] = size
2369
+
2370
+ if size == 0:
2371
+ # Auto mode - recalculate from DPI
2372
+ from libs.toolBar import calculate_icon_size
2373
+ size = calculate_icon_size()
2374
+
2375
+ # Update toolbar icon size
2376
+ if hasattr(self, 'tools') and self.tools:
2377
+ self.tools.update_icon_size(size)
2378
+
2379
+ # Auto-save timer methods (Issue #13)
2380
+ def _toggle_auto_save_timer(self):
2381
+ """Toggle timer-based auto-save."""
2382
+ if self.auto_save_enabled.isChecked():
2383
+ interval = self._get_current_auto_save_interval()
2384
+ self.auto_save_timer.start(interval * 1000) # Convert to ms
2385
+ else:
2386
+ self.auto_save_timer.stop()
2387
+
2388
+ def _set_auto_save_interval(self):
2389
+ """Set auto-save interval from menu selection."""
2390
+ action = self.sender()
2391
+ if action:
2392
+ interval = action.data()
2393
+ if self.auto_save_enabled.isChecked():
2394
+ self.auto_save_timer.start(interval * 1000)
2395
+
2396
+ def _get_current_auto_save_interval(self):
2397
+ """Get currently selected auto-save interval in seconds."""
2398
+ for action in self.auto_save_interval_group.actions():
2399
+ if action.isChecked():
2400
+ return action.data()
2401
+ return 60 # Default 1 minute
2402
+
2403
+ def _auto_save_triggered(self):
2404
+ """Called by timer to perform auto-save."""
2405
+ if not self.dirty:
2406
+ return # Nothing to save
2407
+
2408
+ if not self.file_path:
2409
+ return # No file loaded
2410
+
2411
+ # Determine save path
2412
+ if self.default_save_dir:
2413
+ save_path = self._generate_save_path()
2414
+ else:
2415
+ save_path = self.file_path
2416
+
2417
+ if save_path:
2418
+ self.status("Auto-saving...")
2419
+ if self._save_file(save_path):
2420
+ self.status("Auto-saved to %s" % os.path.basename(save_path))
2421
+
2422
+
2423
+ def inverted(color):
2424
+ return QColor(*[255 - v for v in color.getRgb()])
2425
+
2426
+
2427
+ def read(filename, default=None):
2428
+ try:
2429
+ reader = QImageReader(filename)
2430
+ reader.setAutoTransform(True)
2431
+ image = reader.read()
2432
+ if image.isNull():
2433
+ return default
2434
+ return image
2435
+ except (IOError, OSError):
2436
+ return default
2437
+
2438
+
2439
+ def get_main_app(argv=None):
2440
+ """
2441
+ Standard boilerplate Qt application code.
2442
+ Do everything but app.exec_() -- so that we can test the application in one thread
2443
+ """
2444
+ if not argv:
2445
+ argv = []
2446
+
2447
+ # Enable high-DPI scaling for better icon rendering on HiDPI displays
2448
+ try:
2449
+ QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
2450
+ QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
2451
+ except AttributeError:
2452
+ pass # Qt4 doesn't have these attributes
2453
+
2454
+ app = QApplication(argv)
2455
+ app.setStyle('Fusion') # Use Fusion style for consistent cross-platform styling
2456
+ app.setStyleSheet(get_combined_style()) # Apply global stylesheet
2457
+ app.setApplicationName(__appname__)
2458
+ app.setWindowIcon(new_icon("app"))
2459
+ # Tzutalin 201705+: Accept extra agruments to change predefined class file
2460
+ argparser = argparse.ArgumentParser()
2461
+ argparser.add_argument("image_dir", nargs="?")
2462
+ argparser.add_argument("class_file",
2463
+ default=os.path.join(os.path.dirname(__file__), "data", "predefined_classes.txt"),
2464
+ nargs="?")
2465
+ argparser.add_argument("save_dir", nargs="?")
2466
+ args = argparser.parse_args(argv[1:])
2467
+
2468
+ args.image_dir = args.image_dir and os.path.normpath(args.image_dir)
2469
+ args.class_file = args.class_file and os.path.normpath(args.class_file)
2470
+ args.save_dir = args.save_dir and os.path.normpath(args.save_dir)
2471
+
2472
+ # Usage : labelImg.py image classFile saveDir
2473
+ win = MainWindow(args.image_dir,
2474
+ args.class_file,
2475
+ args.save_dir)
2476
+ win.show()
2477
+ return app, win
2478
+
2479
+
2480
+ def main():
2481
+ """construct main app and run it"""
2482
+ app, _win = get_main_app(sys.argv)
2483
+ return app.exec_()
2484
+
2485
+ if __name__ == '__main__':
2486
+ sys.exit(main())