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 +2486 -0
- labelimgplusplus-2.0.0.dist-info/LICENSE +9 -0
- labelimgplusplus-2.0.0.dist-info/METADATA +330 -0
- labelimgplusplus-2.0.0.dist-info/RECORD +31 -0
- labelimgplusplus-2.0.0.dist-info/WHEEL +5 -0
- labelimgplusplus-2.0.0.dist-info/entry_points.txt +2 -0
- labelimgplusplus-2.0.0.dist-info/top_level.txt +2 -0
- libs/__init__.py +2 -0
- libs/canvas.py +770 -0
- libs/colorDialog.py +37 -0
- libs/combobox.py +33 -0
- libs/commands.py +328 -0
- libs/constants.py +26 -0
- libs/create_ml_io.py +135 -0
- libs/default_label_combobox.py +27 -0
- libs/galleryWidget.py +656 -0
- libs/hashableQListWidgetItem.py +28 -0
- libs/labelDialog.py +95 -0
- libs/labelFile.py +174 -0
- libs/lightWidget.py +33 -0
- libs/pascal_voc_io.py +175 -0
- libs/resources.py +4220 -0
- libs/settings.py +50 -0
- libs/shape.py +209 -0
- libs/stringBundle.py +78 -0
- libs/styles.py +82 -0
- libs/toolBar.py +275 -0
- libs/ustr.py +17 -0
- libs/utils.py +119 -0
- libs/yolo_io.py +158 -0
- libs/zoomWidget.py +26 -0
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())
|