lannotex 0.1.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.
- lannotex/__init__.py +2 -0
- lannotex/__main__.py +4 -0
- lannotex/app.py +924 -0
- lannotex/canvas.py +289 -0
- lannotex/cli.py +97 -0
- lannotex/domain.py +119 -0
- lannotex/exporters.py +212 -0
- lannotex/home.py +130 -0
- lannotex/models.py +51 -0
- lannotex/py.typed +0 -0
- lannotex/storage.py +241 -0
- lannotex/team.py +248 -0
- lannotex/theme.py +58 -0
- lannotex/workers.py +124 -0
- lannotex-0.1.0.dist-info/METADATA +204 -0
- lannotex-0.1.0.dist-info/RECORD +21 -0
- lannotex-0.1.0.dist-info/WHEEL +5 -0
- lannotex-0.1.0.dist-info/entry_points.txt +3 -0
- lannotex-0.1.0.dist-info/licenses/LICENSE +21 -0
- lannotex-0.1.0.dist-info/licenses/LICENSES/THIRD_PARTY_NOTICES.md +11 -0
- lannotex-0.1.0.dist-info/top_level.txt +1 -0
lannotex/app.py
ADDED
|
@@ -0,0 +1,924 @@
|
|
|
1
|
+
"""Lannotex Desktop: local project orchestration and native Qt workspace."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
import uuid
|
|
9
|
+
from PySide6.QtCore import QEvent, QModelIndex, QSettings, Qt, QThreadPool, QTimer, Slot
|
|
10
|
+
from PySide6.QtGui import QAction, QColor, QKeySequence
|
|
11
|
+
from PySide6.QtWidgets import (QAbstractSpinBox, QApplication, QCheckBox, QComboBox,
|
|
12
|
+
QDialog, QDialogButtonBox, QDoubleSpinBox, QFileDialog, QFrame, QGridLayout,
|
|
13
|
+
QHBoxLayout, QInputDialog, QKeySequenceEdit, QLabel, QLineEdit, QListView, QListWidget,
|
|
14
|
+
QListWidgetItem, QMainWindow, QMessageBox, QProgressBar, QPushButton, QScrollArea,
|
|
15
|
+
QSplitter, QStackedWidget, QTableWidget, QTableWidgetItem, QTextEdit, QToolButton, QVBoxLayout, QWidget)
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .canvas import Canvas
|
|
18
|
+
from .domain import Annotation, Document, validate_all
|
|
19
|
+
from .exporters import analyze, export_project, import_annotations
|
|
20
|
+
from .models import ImageListModel
|
|
21
|
+
from .storage import ConflictError, Project, atomic_text
|
|
22
|
+
from .theme import apply_theme
|
|
23
|
+
from .workers import ImageLoader, Job, import_images
|
|
24
|
+
from .home import Home, CreateProjectDialog, PIPELINES
|
|
25
|
+
from .team import TeamClient, TeamServer, add_member, sync_replica, resolve_conflict
|
|
26
|
+
|
|
27
|
+
COLORS = ['#37b8a5','#70a8f5','#d5a85a','#bf8bef','#ed8ca4','#74c9db','#9fbd68']
|
|
28
|
+
DEFAULT_KEYS = {'save':'Ctrl+S','save_next':'Ctrl+Return','previous':'Alt+Left','next':'Alt+Right',
|
|
29
|
+
'undo':'Ctrl+Z','redo':'Ctrl+Shift+Z','delete':'Del','cancel':'Esc','finish':'Return',
|
|
30
|
+
'fit':'F','select':'V','box':'B','polygon':'P','duplicate':'Ctrl+D','redraw':'R',
|
|
31
|
+
'vertex':'Shift+Del','focus':'Ctrl+Shift+F','open':'Ctrl+O','import':'Ctrl+I'}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def button(text, callback, primary=False):
|
|
35
|
+
widget = QPushButton(text)
|
|
36
|
+
if primary: widget.setObjectName('primary')
|
|
37
|
+
widget.clicked.connect(callback)
|
|
38
|
+
return widget
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def label(text, style='muted'):
|
|
42
|
+
widget = QLabel(text); widget.setObjectName(style); return widget
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Main(QMainWindow):
|
|
46
|
+
def __init__(self):
|
|
47
|
+
super().__init__()
|
|
48
|
+
self.setWindowTitle(f'Lannotex Desktop · {__version__}')
|
|
49
|
+
self.resize(1440,940); self.setMinimumSize(900,620)
|
|
50
|
+
# Explicit INI format: the (org, app) constructor ignores setDefaultFormat/setPath, so tests couldn't isolate it.
|
|
51
|
+
self.settings = QSettings(QSettings.IniFormat, QSettings.UserScope, 'Lannotex', 'Desktop')
|
|
52
|
+
self.bindings = dict(DEFAULT_KEYS)
|
|
53
|
+
stored = self.settings.value('keys', {})
|
|
54
|
+
if isinstance(stored, dict): self.bindings.update(stored)
|
|
55
|
+
self.dark = self.settings.value('dark',False,type=bool)
|
|
56
|
+
apply_theme(QApplication.instance(),self.dark)
|
|
57
|
+
self.project = None; self.current = None; self.document = Document()
|
|
58
|
+
self.dirty = False; self.loading = False; self.guard = False; self.token = 0
|
|
59
|
+
self.job = None; self.job_kind = ''; self.focus_mode = False
|
|
60
|
+
self.pool = QThreadPool(self); self.pool.setMaxThreadCount(1)
|
|
61
|
+
self.loader = ImageLoader(self)
|
|
62
|
+
self.loader.ready.connect(self.image_ready); self.loader.failed.connect(self.image_failed)
|
|
63
|
+
self.timer = QTimer(self); self.timer.setSingleShot(True); self.timer.timeout.connect(self.save)
|
|
64
|
+
self.filter_timer = QTimer(self); self.filter_timer.setSingleShot(True); self.filter_timer.timeout.connect(self.filter_images)
|
|
65
|
+
self.actions = {}; self._build_ui(); self._build_menus()
|
|
66
|
+
self.workspace=self.takeCentralWidget(); self.workspace.setObjectName('workspace')
|
|
67
|
+
self.home=Home(); self.pages=QStackedWidget(); self.pages.addWidget(self.home); self.pages.addWidget(self.workspace)
|
|
68
|
+
self.setCentralWidget(self.pages)
|
|
69
|
+
self.home.create_requested.connect(self.new_project); self.home.open_requested.connect(self.open_project)
|
|
70
|
+
self.home.continue_requested.connect(self.load_project); self.home.theme_requested.connect(self.toggle_theme)
|
|
71
|
+
self.home.preferences_requested.connect(self.preferences); self.home.join_requested.connect(self.join_team)
|
|
72
|
+
self.home.refresh(self.settings.value('recent_projects',[],type=list))
|
|
73
|
+
self.host=None; self.team_client=None; self.team_token=''; self.sync_job=None; self.pending_team=None; self.current_revision=None
|
|
74
|
+
self.sync_timer=QTimer(self); self.sync_timer.setInterval(4000); self.sync_timer.timeout.connect(self.sync_team)
|
|
75
|
+
QApplication.instance().installEventFilter(self)
|
|
76
|
+
geometry = self.settings.value('geometry')
|
|
77
|
+
if geometry: self.restoreGeometry(geometry)
|
|
78
|
+
sizes = self.settings.value('splitter')
|
|
79
|
+
if sizes: self.splitter.restoreState(sizes)
|
|
80
|
+
self.set_save_state('No project'); self.refresh_controls()
|
|
81
|
+
|
|
82
|
+
def _build_ui(self):
|
|
83
|
+
root = QWidget(); outer = QVBoxLayout(root); outer.setContentsMargins(0,0,0,0); outer.setSpacing(0)
|
|
84
|
+
header=QFrame(); header.setObjectName('header'); bar=QHBoxLayout(header); bar.setContentsMargins(20,14,20,14)
|
|
85
|
+
bar.addWidget(button('‹ Home',self.go_home))
|
|
86
|
+
brand=QVBoxLayout(); brand.setSpacing(1); brand.addWidget(label('Lannotex','brand')); self.pipeline_title=label('ANNOTATION WORKSPACE'); brand.addWidget(self.pipeline_title)
|
|
87
|
+
bar.addLayout(brand); bar.addSpacing(28)
|
|
88
|
+
self.project_title=label('No project open'); bar.addWidget(self.project_title,1)
|
|
89
|
+
bar.addWidget(button('New project',self.new_project)); bar.addWidget(button('Open',self.open_project))
|
|
90
|
+
self.team_button=button('Team',self.team_dialog); bar.addWidget(self.team_button)
|
|
91
|
+
self.sync_button=button('Sync',self.sync_team); bar.addWidget(self.sync_button); self.sync_button.hide()
|
|
92
|
+
bar.addWidget(button('◐',self.toggle_theme))
|
|
93
|
+
self.analysis_button=button('Analysis',self.show_analysis); bar.addWidget(self.analysis_button)
|
|
94
|
+
self.export_button=button('Export',self.export_dialog,True); bar.addWidget(self.export_button)
|
|
95
|
+
outer.addWidget(header)
|
|
96
|
+
self.splitter=QSplitter(); self.splitter.setChildrenCollapsible(False)
|
|
97
|
+
self.left=QFrame(); self.left.setObjectName('panel'); left=QVBoxLayout(self.left); left.setContentsMargins(12,10,12,10)
|
|
98
|
+
left.addWidget(label('DATASET','section'))
|
|
99
|
+
self.dataset_count=label('0 images'); left.addWidget(self.dataset_count)
|
|
100
|
+
row=QHBoxLayout(); self.import_button=button('+ Folder',self.import_folder); row.addWidget(self.import_button)
|
|
101
|
+
self.files_button=button('+ Files',self.import_files); row.addWidget(self.files_button); left.addLayout(row)
|
|
102
|
+
self.search=QLineEdit(); self.search.setPlaceholderText('Search image names…'); self.search.setClearButtonEnabled(True)
|
|
103
|
+
self.search.textChanged.connect(lambda:self.filter_timer.start(180)); left.addWidget(self.search)
|
|
104
|
+
self.filter=QComboBox()
|
|
105
|
+
for text,data in [('All images','all'),('Unlabeled','new'),('Labeled','done'),('No targets','empty')]: self.filter.addItem(text,data)
|
|
106
|
+
self.filter.currentIndexChanged.connect(self.filter_images); left.addWidget(self.filter)
|
|
107
|
+
self.model=ImageListModel(self); self.images=QListView(); self.images.setModel(self.model)
|
|
108
|
+
self.images.setUniformItemSizes(True); self.images.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
|
109
|
+
self.images.setAccessibleName('Project images'); self.images.selectionModel().currentChanged.connect(self.select_index)
|
|
110
|
+
left.addWidget(self.images,1)
|
|
111
|
+
self.left_hint=label('Images stay on your disk.\nNothing is uploaded.'); left.addWidget(self.left_hint)
|
|
112
|
+
self.left.setMinimumWidth(220); self.splitter.addWidget(self.left)
|
|
113
|
+
center=QWidget(); middle=QVBoxLayout(center); middle.setContentsMargins(8,0,8,0); middle.setSpacing(8)
|
|
114
|
+
top=QHBoxLayout(); self.image_title=label('Annotation studio','section'); top.addWidget(self.image_title,1)
|
|
115
|
+
self.save_badge=label('No project','save'); top.addWidget(self.save_badge); middle.addLayout(top)
|
|
116
|
+
tools=QHBoxLayout(); self.tool_buttons={}
|
|
117
|
+
for text,mode,key in [('Select','select','V'),('Box','box','B'),('Polygon','polygon','P')]:
|
|
118
|
+
tool=QToolButton(); tool.setText(f'{text} {key}'); tool.setCheckable(True); tool.setToolTip(f'{text} tool ({key})')
|
|
119
|
+
tool.clicked.connect(lambda checked=False,m=mode:self.set_tool(m)); tools.addWidget(tool); self.tool_buttons[mode]=tool
|
|
120
|
+
self.tool_buttons['select'].setChecked(True)
|
|
121
|
+
tools.addStretch(); self.undo_button=button('↶',self.undo); self.undo_button.setToolTip('Undo (Ctrl+Z)'); tools.addWidget(self.undo_button)
|
|
122
|
+
self.redo_button=button('↷',self.redo); self.redo_button.setToolTip('Redo (Ctrl+Shift+Z)'); tools.addWidget(self.redo_button)
|
|
123
|
+
self.zoom_label=label('100%'); tools.addWidget(self.zoom_label); tools.addWidget(button('Fit',lambda:self.canvas.fit()))
|
|
124
|
+
middle.addLayout(tools)
|
|
125
|
+
self.canvas=Canvas(); self.canvas.edited.connect(self.commit); self.canvas.selected.connect(self.selection_changed)
|
|
126
|
+
self.canvas.message.connect(self.notice); self.canvas.zoomed.connect(lambda n:self.zoom_label.setText(f'{n}%'))
|
|
127
|
+
middle.addWidget(self.canvas,1)
|
|
128
|
+
self.canvas_hint=label('B Draw box · P Draw polygon · Space + drag Pan · F Fit')
|
|
129
|
+
self.canvas_hint.setWordWrap(True); middle.addWidget(self.canvas_hint)
|
|
130
|
+
footer=QHBoxLayout(); footer.addWidget(button('←',lambda:self.navigate(-1)))
|
|
131
|
+
self.position=label('— / —'); footer.addWidget(self.position); footer.addWidget(button('→',lambda:self.navigate(1)))
|
|
132
|
+
footer.addStretch(); self.save_button=button('Save',self.save); footer.addWidget(self.save_button)
|
|
133
|
+
self.next_button=button('Save && next→',self.save_next,True); footer.addWidget(self.next_button); middle.addLayout(footer)
|
|
134
|
+
self.splitter.addWidget(center)
|
|
135
|
+
self.right=QScrollArea(); self.right.setWidgetResizable(True); self.right.setMinimumWidth(245)
|
|
136
|
+
inspector=QWidget(); form=QVBoxLayout(inspector); form.setContentsMargins(14,10,14,14); form.setSpacing(9)
|
|
137
|
+
self.inspector_heading=label('LABELS & ANNOTATIONS','section'); form.addWidget(self.inspector_heading)
|
|
138
|
+
labelbar=QHBoxLayout(); self.label_combo=QComboBox(); self.label_combo.setMinimumWidth(100)
|
|
139
|
+
self.label_combo.currentTextChanged.connect(self.label_changed); labelbar.addWidget(self.label_combo,1)
|
|
140
|
+
self.add_label_button=button('+',self.add_label); self.add_label_button.setToolTip('Add a named label'); labelbar.addWidget(self.add_label_button)
|
|
141
|
+
form.addLayout(labelbar)
|
|
142
|
+
tax=QHBoxLayout(); self.rename_button=button('Rename',self.rename_label); self.remove_label_button=button('Remove',self.remove_label)
|
|
143
|
+
tax.addWidget(self.rename_button); tax.addWidget(self.remove_label_button); form.addLayout(tax)
|
|
144
|
+
self.classification_heading=label('IMAGE CLASSIFICATION','section'); form.addWidget(self.classification_heading)
|
|
145
|
+
self.classes=QListWidget(); self.classes.setMinimumHeight(70); self.classes.setMaximumHeight(150)
|
|
146
|
+
self.classes.itemChanged.connect(self.class_changed); form.addWidget(self.classes)
|
|
147
|
+
self.classes.currentItemChanged.connect(self.class_focused)
|
|
148
|
+
self.negative=QCheckBox('No target objects in this image'); self.negative.toggled.connect(self.negative_changed); form.addWidget(self.negative)
|
|
149
|
+
self.objects_heading=label('OBJECTS','section'); form.addWidget(self.objects_heading)
|
|
150
|
+
self.annotation_list=QListWidget(); self.annotation_list.setMinimumHeight(110); self.annotation_list.setMaximumHeight(220)
|
|
151
|
+
self.annotation_list.currentItemChanged.connect(self.annotation_selected); form.addWidget(self.annotation_list)
|
|
152
|
+
self.selection_info=label('Select an object to edit its geometry.'); self.selection_info.setWordWrap(True); form.addWidget(self.selection_info)
|
|
153
|
+
self.geometry_widget=QWidget(); grid=QGridLayout(self.geometry_widget); grid.setContentsMargins(0,0,0,0); self.coordinates=[]
|
|
154
|
+
for i,name in enumerate(['Left %','Top %','Right %','Bottom %']):
|
|
155
|
+
box=QDoubleSpinBox(); box.setRange(0,100); box.setDecimals(3); box.setSingleStep(.1)
|
|
156
|
+
grid.addWidget(label(name),i//2*2,i%2); grid.addWidget(box,i//2*2+1,i%2)
|
|
157
|
+
box.editingFinished.connect(self.geometry_changed); self.coordinates.append(box)
|
|
158
|
+
form.addWidget(self.geometry_widget); self.geometry_widget.hide()
|
|
159
|
+
editrow=QHBoxLayout(); self.duplicate_button=button('Duplicate',self.duplicate); editrow.addWidget(self.duplicate_button)
|
|
160
|
+
self.redraw_button=button('Redraw',lambda:self.canvas.redraw()); editrow.addWidget(self.redraw_button); form.addLayout(editrow)
|
|
161
|
+
self.delete_button=button('Delete selected',self.delete_selected); self.delete_button.setObjectName('danger'); form.addWidget(self.delete_button)
|
|
162
|
+
form.addWidget(button('Clear image annotations',self.clear_annotations))
|
|
163
|
+
from PySide6.QtWidgets import QSlider
|
|
164
|
+
self.opacity_heading=label('OVERLAY OPACITY','section'); form.addWidget(self.opacity_heading); self.opacity=QSlider(Qt.Horizontal); self.opacity.setRange(0,70)
|
|
165
|
+
self.opacity.setValue(self.settings.value('opacity',15,type=int)); self.canvas.opacity=self.opacity.value()/100
|
|
166
|
+
self.opacity.valueChanged.connect(self.opacity_changed); form.addWidget(self.opacity)
|
|
167
|
+
self.polygon_hint=label('Polygon: drag a vertex to edit. Double-click an edge to insert. Shift+Delete removes the selected vertex.'); self.polygon_hint.setWordWrap(True); form.addWidget(self.polygon_hint)
|
|
168
|
+
form.addStretch(); form.addWidget(button('Keyboard && preferences',self.preferences))
|
|
169
|
+
self.right.setWidget(inspector); self.splitter.addWidget(self.right); self.splitter.setSizes([260,850,280])
|
|
170
|
+
wrap=QWidget(); layout=QVBoxLayout(wrap); layout.setContentsMargins(12,12,12,12); layout.addWidget(self.splitter)
|
|
171
|
+
outer.addWidget(wrap,1); self.setCentralWidget(root)
|
|
172
|
+
self.status=label('Ready · local-only workspace'); self.statusBar().addWidget(self.status,1)
|
|
173
|
+
self.progress=QProgressBar(); self.progress.setMaximumWidth(180); self.progress.hide(); self.statusBar().addPermanentWidget(self.progress)
|
|
174
|
+
self.cancel_button=button('Cancel job',self.cancel_job); self.cancel_button.hide(); self.statusBar().addPermanentWidget(self.cancel_button)
|
|
175
|
+
|
|
176
|
+
def _build_menus(self):
|
|
177
|
+
def add(menu,text,slot,key=None):
|
|
178
|
+
action=QAction(text,self); action.triggered.connect(slot); menu.addAction(action)
|
|
179
|
+
if key: self.actions[key]=(action,text,slot)
|
|
180
|
+
return action
|
|
181
|
+
file=self.menuBar().addMenu('Project')
|
|
182
|
+
add(file,'New project…',self.new_project); add(file,'Open project…',self.open_project,'open')
|
|
183
|
+
add(file,'Import image folder…',self.import_folder,'import'); add(file,'Import image files…',self.import_files)
|
|
184
|
+
add(file,'Import annotations…',self.import_annotation_dialog); file.addSeparator()
|
|
185
|
+
add(file,'Save',self.save,'save'); add(file,'Save && next',self.save_next,'save_next')
|
|
186
|
+
add(file,'Export…',self.export_dialog); add(file,'Backup project…',self.backup_dialog)
|
|
187
|
+
add(file,'Restore backup…',self.restore_dialog); add(file,'Relink current image…',self.relink_dialog)
|
|
188
|
+
add(file,'Project tasks…',self.task_settings)
|
|
189
|
+
edit=self.menuBar().addMenu('Edit')
|
|
190
|
+
for key,text,slot in [('undo','Undo',self.undo),('redo','Redo',self.redo),('duplicate','Duplicate',self.duplicate),
|
|
191
|
+
('delete','Delete annotation',self.delete_selected),('redraw','Redraw',self.canvas.redraw),
|
|
192
|
+
('vertex','Delete polygon vertex',self.canvas.delete_vertex),('cancel','Cancel drawing',self.canvas.cancel),
|
|
193
|
+
('finish','Finish polygon',self.canvas.finish_polygon)]: add(edit,text,slot,key)
|
|
194
|
+
view=self.menuBar().addMenu('View')
|
|
195
|
+
add(view,'Fit image',self.canvas.fit,'fit'); add(view,'Focus mode',self.toggle_focus,'focus')
|
|
196
|
+
add(view,'Toggle light / dark',self.toggle_theme); add(view,'Analysis',self.show_analysis)
|
|
197
|
+
add(view,'Previous image',lambda:self.navigate(-1),'previous'); add(view,'Next image',lambda:self.navigate(1),'next')
|
|
198
|
+
for mode in ('select','box','polygon'): add(view,mode.title()+' tool',lambda checked=False,m=mode:self.set_tool(m),mode)
|
|
199
|
+
helpmenu=self.menuBar().addMenu('Help'); add(helpmenu,'Keyboard && preferences',self.preferences)
|
|
200
|
+
add(helpmenu,'About Lannotex',lambda:QMessageBox.information(self,'Lannotex Desktop',f'Lannotex Desktop {__version__}\nLocal image annotation. No login, telemetry or uploads.\n\nDisplay previews are capped at 2560 px. Geometry uses original dimensions.'))
|
|
201
|
+
self.update_action_labels()
|
|
202
|
+
|
|
203
|
+
def update_action_labels(self):
|
|
204
|
+
for key,(action,text,_) in self.actions.items(): action.setText(text+'\t'+self.bindings.get(key,''))
|
|
205
|
+
|
|
206
|
+
def eventFilter(self, obj, event):
|
|
207
|
+
if event.type()!=QEvent.KeyPress or QApplication.activeWindow()!=self: return super().eventFilter(obj,event)
|
|
208
|
+
if hasattr(self,'pages') and self.pages.currentWidget()==self.home: return super().eventFilter(obj,event)
|
|
209
|
+
seq=QKeySequence(event.key() | event.modifiers().value).toString()
|
|
210
|
+
typing=isinstance(QApplication.focusWidget(),(QLineEdit,QTextEdit,QAbstractSpinBox,QComboBox,QKeySequenceEdit))
|
|
211
|
+
for key,binding in self.bindings.items():
|
|
212
|
+
if seq != QKeySequence(binding).toString(): continue
|
|
213
|
+
if typing and key not in ('save','save_next'): return False
|
|
214
|
+
if key in self.actions:
|
|
215
|
+
self.actions[key][2](); return True
|
|
216
|
+
if not typing and event.key() in (Qt.Key_Left,Qt.Key_Right,Qt.Key_Up,Qt.Key_Down) and event.modifiers() in (Qt.NoModifier,Qt.ShiftModifier):
|
|
217
|
+
self.nudge(event.key(), .01 if event.modifiers()==Qt.ShiftModifier else .001); return True
|
|
218
|
+
return super().eventFilter(obj,event)
|
|
219
|
+
|
|
220
|
+
def notice(self, message): self.status.setText(message)
|
|
221
|
+
|
|
222
|
+
def fail(self, title, error):
|
|
223
|
+
self.notice(f'{title}: {error}')
|
|
224
|
+
QMessageBox.warning(self,title,str(error))
|
|
225
|
+
|
|
226
|
+
def set_save_state(self, text):
|
|
227
|
+
self.save_badge.setText(text)
|
|
228
|
+
self.setWindowTitle(f"{'● ' if self.dirty else ''}Lannotex Desktop · {self.project.root.name if self.project else __version__}")
|
|
229
|
+
|
|
230
|
+
def refresh_controls(self):
|
|
231
|
+
opened=self.project is not None; ready=opened and self.current is not None and not self.loading and self.canvas.image is not None
|
|
232
|
+
for b in (self.import_button,self.files_button,self.analysis_button,self.export_button,self.add_label_button): b.setEnabled(opened and self.job is None)
|
|
233
|
+
remote=bool(self.project.setting('team_remote')) if opened else False
|
|
234
|
+
self.left_hint.setText('Local cache · shared with\nyour chosen team host.' if remote else 'Images stay on your disk.\nNothing is uploaded automatically.')
|
|
235
|
+
self.team_button.setEnabled(opened); self.sync_button.setVisible(remote)
|
|
236
|
+
for b in (self.import_button,self.files_button,self.add_label_button,self.rename_button,self.remove_label_button):
|
|
237
|
+
b.setEnabled(opened and self.job is None and not remote)
|
|
238
|
+
self.label_combo.setEnabled(not remote or ready)
|
|
239
|
+
for b in (self.save_button,self.next_button,self.negative,self.classes,self.annotation_list): b.setEnabled(ready)
|
|
240
|
+
tasks=self.project.setting('tasks',['box','polygon','classification']) if opened else []
|
|
241
|
+
objects=bool(set(tasks)&{'box','polygon'})
|
|
242
|
+
for mode,b in self.tool_buttons.items():
|
|
243
|
+
b.setEnabled(ready and (mode=='select' or mode in tasks)); b.setVisible(objects if mode=='select' else mode in tasks)
|
|
244
|
+
for widget in (self.classes,self.classification_heading): widget.setVisible('classification' in tasks)
|
|
245
|
+
for widget in (self.objects_heading,self.annotation_list,self.selection_info,self.duplicate_button,self.redraw_button,self.delete_button,self.opacity,self.opacity_heading): widget.setVisible(objects)
|
|
246
|
+
self.polygon_hint.setVisible('polygon' in tasks)
|
|
247
|
+
self.label_combo.setVisible(objects)
|
|
248
|
+
self.inspector_heading.setText('CLASSES' if tasks==['classification'] else 'LABELS & ANNOTATIONS')
|
|
249
|
+
self.add_label_button.setText('+ Class' if tasks==['classification'] else '+')
|
|
250
|
+
self.negative.setText('None of these classes apply' if tasks==['classification'] else 'No target objects in this image')
|
|
251
|
+
if tasks==['classification']: self.canvas_hint.setText('Select all classes that describe this image · Save & next to continue')
|
|
252
|
+
if tasks==['classification']: self.geometry_widget.hide()
|
|
253
|
+
self.classes.setEnabled(ready and 'classification' in tasks)
|
|
254
|
+
self.undo_button.setEnabled(ready and bool(self.document.undo_stack)); self.redo_button.setEnabled(ready and bool(self.document.redo_stack))
|
|
255
|
+
selected=ready and self.canvas.selection is not None
|
|
256
|
+
for b in (self.duplicate_button,self.redraw_button,self.delete_button): b.setEnabled(selected)
|
|
257
|
+
|
|
258
|
+
def new_project(self,pipeline='classification'):
|
|
259
|
+
if not isinstance(pipeline,str): pipeline='classification'
|
|
260
|
+
if self.job or not self.save(): return
|
|
261
|
+
dialog=CreateProjectDialog(pipeline,self)
|
|
262
|
+
if dialog.exec()!=QDialog.Accepted: return
|
|
263
|
+
try:
|
|
264
|
+
project=Project(dialog.folder)
|
|
265
|
+
project.set_setting('tasks',PIPELINES[dialog.pipeline][2]); project.set_setting('pipeline',dialog.pipeline)
|
|
266
|
+
project.set_setting('mode',dialog.mode.currentData()); project.set_setting('name',dialog.name.text().strip())
|
|
267
|
+
for i,name in enumerate(s.strip() for s in dialog.classes.toPlainText().splitlines() if s.strip()): project.add_label(name,COLORS[i%len(COLORS)])
|
|
268
|
+
project.close()
|
|
269
|
+
except Exception as error: self.fail('Could not create project',error); return
|
|
270
|
+
if self.load_project(dialog.folder) and dialog.mode.currentData()=='team': self.team_dialog()
|
|
271
|
+
|
|
272
|
+
def open_project(self):
|
|
273
|
+
folder=QFileDialog.getExistingDirectory(self,'Open project folder',self.settings.value('last_project',''))
|
|
274
|
+
if folder: self.load_project(folder)
|
|
275
|
+
|
|
276
|
+
def load_project(self, folder, create=False, client=None):
|
|
277
|
+
if self.sync_job: self.notice('Wait for the current team sync before switching.'); return False
|
|
278
|
+
if self.host and Path(folder).resolve()!=self.project.root:
|
|
279
|
+
self.notice('Stop hosting from Team before switching projects.'); return False
|
|
280
|
+
if self.job: self.notice('Finish or cancel the background job before switching projects.'); return False
|
|
281
|
+
if not self.save(): return False
|
|
282
|
+
try: next_project=Project(folder,create=create)
|
|
283
|
+
except Exception as error: self.fail('Cannot open project',error); return False
|
|
284
|
+
self.timer.stop(); self.token+=1
|
|
285
|
+
self.sync_timer.stop(); self.team_client=client; self.team_token=''
|
|
286
|
+
if self.project: self.project.close()
|
|
287
|
+
self.project=next_project; self.current=None; self.document=Document(); self.dirty=False; self.loading=False
|
|
288
|
+
self.canvas.set_image(None); self.canvas.set_annotations([])
|
|
289
|
+
self.project_title.setText(self.project.root.name); self.project_title.setToolTip(str(self.project.root))
|
|
290
|
+
self.settings.setValue('last_project',str(self.project.root))
|
|
291
|
+
recent=self.settings.value('recent_projects',[],type=list)
|
|
292
|
+
recent=[str(self.project.root)]+[p for p in recent if p!=str(self.project.root)]
|
|
293
|
+
self.settings.setValue('recent_projects',recent[:12]); self.home.refresh(recent[:12])
|
|
294
|
+
self.pipeline_title.setText(' / '.join({'box':'DETECTION','polygon':'SEGMENTATION','classification':'CLASSIFICATION'}[t] for t in self.project.setting('tasks',['box','polygon','classification'])))
|
|
295
|
+
self.pages.setCurrentWidget(self.workspace)
|
|
296
|
+
self.search.blockSignals(True); self.search.clear(); self.search.blockSignals(False)
|
|
297
|
+
self.filter.blockSignals(True); self.filter.setCurrentIndex(0); self.filter.blockSignals(False)
|
|
298
|
+
self.refresh_labels(); self.refresh_images(); self.set_save_state('Saved'); self.refresh_controls()
|
|
299
|
+
self.notice('Project open. Add labels, import images, and start annotating.')
|
|
300
|
+
if self.team_client: self.sync_timer.start()
|
|
301
|
+
return True
|
|
302
|
+
|
|
303
|
+
def go_home(self):
|
|
304
|
+
if not self.save(): return
|
|
305
|
+
self.canvas.cancel(); self.home.refresh(self.settings.value('recent_projects',[],type=list)); self.pages.setCurrentWidget(self.home)
|
|
306
|
+
|
|
307
|
+
def refresh_images(self):
|
|
308
|
+
self.model.refresh(self.project,self.search.text(),self.filter.currentData())
|
|
309
|
+
total=self.project.query(count=True) if self.project else 0
|
|
310
|
+
self.dataset_count.setText(f'{total:,} images · {self.model.total:,} shown')
|
|
311
|
+
if self.model.total and self.current is None: self.images.setCurrentIndex(self.model.index(0))
|
|
312
|
+
elif not self.model.total and self.current is None:
|
|
313
|
+
self.canvas.empty_title='Ready for your images'
|
|
314
|
+
self.canvas.empty_detail='Import a folder or select image files using the dataset panel.'
|
|
315
|
+
self.canvas.update()
|
|
316
|
+
|
|
317
|
+
def filter_images(self):
|
|
318
|
+
if not self.project: return
|
|
319
|
+
if not self.save(): return
|
|
320
|
+
self.refresh_images()
|
|
321
|
+
|
|
322
|
+
@Slot(QModelIndex,QModelIndex)
|
|
323
|
+
def select_index(self,index,previous=QModelIndex()):
|
|
324
|
+
if not index.isValid() or not self.project: return
|
|
325
|
+
record=self.model.record(index.row())
|
|
326
|
+
if self.current and record['id']==self.current['id'] and (self.canvas.image is not None or self.loading): return
|
|
327
|
+
if not self.save():
|
|
328
|
+
self.images.selectionModel().blockSignals(True); self.images.setCurrentIndex(previous); self.images.selectionModel().blockSignals(False); return
|
|
329
|
+
self.canvas.cancel(); self.timer.stop(); self.token+=1; self.current=dict(record)
|
|
330
|
+
annotations,negative,recovered,revision=self.project.load_document(record['id'])
|
|
331
|
+
self.document=Document(annotations,negative); self.dirty=recovered; self.loading=True
|
|
332
|
+
self.current_revision=revision
|
|
333
|
+
self.canvas.empty_title='Loading image…'; self.canvas.empty_detail=Path(record['path']).name
|
|
334
|
+
self.canvas.set_image(None); self.canvas.set_annotations(annotations)
|
|
335
|
+
self.image_title.setText(Path(record['path']).name); self.image_title.setToolTip(str(self.project.resolve(record['path'])))
|
|
336
|
+
self.position.setText(f'{index.row()+1:,} / {self.model.total:,}')
|
|
337
|
+
self.set_save_state('Recovered draft' if recovered else 'Saved'); self.sync_inspector(); self.refresh_controls()
|
|
338
|
+
path=self.project.resolve(record['path'])
|
|
339
|
+
if self.team_client and not path.is_file():
|
|
340
|
+
if self.job: self.notice('Wait for the current operation, then select the image again.'); self.loading=False; return
|
|
341
|
+
client=self.team_client; token=self.token; ident=record['id']
|
|
342
|
+
def download(cancel,progress):
|
|
343
|
+
client.download(ident,path,cancel); return (token,str(path))
|
|
344
|
+
self.start_job('download',download)
|
|
345
|
+
else: self.loader.request(self.token,path)
|
|
346
|
+
|
|
347
|
+
@Slot(object,object)
|
|
348
|
+
def image_ready(self,token,image):
|
|
349
|
+
if token!=self.token: return
|
|
350
|
+
self.loading=False; self.canvas.set_image(image); self.refresh_controls()
|
|
351
|
+
self.notice(f"{self.current['width']} × {self.current['height']} px · full-resolution coordinates · local project")
|
|
352
|
+
if self.dirty and self.settings.value('autosave',True,type=bool): self.timer.start(700)
|
|
353
|
+
|
|
354
|
+
@Slot(object,str)
|
|
355
|
+
def image_failed(self,token,error):
|
|
356
|
+
if token!=self.token: return
|
|
357
|
+
self.loading=False; self.canvas.empty_title='Image unavailable'
|
|
358
|
+
self.canvas.empty_detail='Project → Relink current image to locate the original file.\n'+error
|
|
359
|
+
self.canvas.update(); self.notice(error); self.refresh_controls()
|
|
360
|
+
|
|
361
|
+
def navigate(self,step):
|
|
362
|
+
if not self.project: return
|
|
363
|
+
index=self.images.currentIndex().row()
|
|
364
|
+
next_row=max(0,min(self.model.total-1,index+step))
|
|
365
|
+
if self.model.total: self.images.setCurrentIndex(self.model.index(next_row))
|
|
366
|
+
|
|
367
|
+
def save_next(self):
|
|
368
|
+
if self.save(): self.navigate(1)
|
|
369
|
+
|
|
370
|
+
def save(self):
|
|
371
|
+
if self.canvas.points or (self.canvas.drag and self.canvas.drag['kind']!='pan'):
|
|
372
|
+
self.notice('Finish or cancel the current drawing before saving or leaving this image.'); return False
|
|
373
|
+
if not self.project or not self.current or not self.dirty: return True
|
|
374
|
+
self.timer.stop(); self.set_save_state('Saving…')
|
|
375
|
+
try:
|
|
376
|
+
self.project.save_annotations(self.current['id'],self.document.annotations,self.document.negative,
|
|
377
|
+
expected_revision=self.current_revision)
|
|
378
|
+
self.current_revision=self.project.conn.execute('SELECT revision FROM revisions WHERE image_id=?',(self.current['id'],)).fetchone()[0]
|
|
379
|
+
except Exception as error:
|
|
380
|
+
if isinstance(error,ConflictError) and self.project.setting('team_remote'):
|
|
381
|
+
with self.project.conn: self.project.conn.execute('UPDATE team_links SET conflict=1 WHERE image_id=?',(self.current['id'],))
|
|
382
|
+
self.set_save_state('Save failed · retry'); self.notice(f'Save failed: {error}. Your edits are still open.'); return False
|
|
383
|
+
self.dirty=False; self.set_save_state('Saved locally · queued' if self.project.setting('team_remote') else 'Saved'); self.model.invalidate(); return True
|
|
384
|
+
|
|
385
|
+
def commit(self,annotations,negative=False):
|
|
386
|
+
if not self.current or self.loading or self.canvas.image is None: return
|
|
387
|
+
errors=validate_all(annotations,set(self.canvas.colors),negative)
|
|
388
|
+
allowed=set(self.project.setting('tasks',['box','polygon','classification']))
|
|
389
|
+
old_ids={a.id for a in self.document.annotations}
|
|
390
|
+
if any(a.kind not in allowed and a.id not in old_ids for a in annotations): errors.append('This annotation type is not enabled in the project pipeline')
|
|
391
|
+
if errors: self.notice(errors[0]); return
|
|
392
|
+
if self.document.change(annotations,negative): self.document_changed()
|
|
393
|
+
|
|
394
|
+
def document_changed(self):
|
|
395
|
+
self.dirty=True
|
|
396
|
+
self.canvas.set_annotations(self.document.annotations)
|
|
397
|
+
self.sync_inspector(); self.refresh_controls(); self.set_save_state('Unsaved')
|
|
398
|
+
try: self.project.save_draft(self.current['id'],self.document.annotations,self.document.negative,self.current_revision)
|
|
399
|
+
except Exception as error:
|
|
400
|
+
self.set_save_state('Draft write failed'); self.notice(f'Keep this window open: {error}')
|
|
401
|
+
if self.settings.value('autosave',True,type=bool): self.timer.start(700)
|
|
402
|
+
|
|
403
|
+
def undo(self):
|
|
404
|
+
if not self.current or self.loading: return
|
|
405
|
+
self.canvas.cancel()
|
|
406
|
+
if self.document.undo(): self.document_changed()
|
|
407
|
+
|
|
408
|
+
def redo(self):
|
|
409
|
+
if not self.current or self.loading: return
|
|
410
|
+
self.canvas.cancel()
|
|
411
|
+
if self.document.redo(): self.document_changed()
|
|
412
|
+
|
|
413
|
+
def set_tool(self,mode):
|
|
414
|
+
if not self.current or self.loading: return
|
|
415
|
+
if mode!='select' and mode not in self.project.setting('tasks',['box','polygon','classification']): return
|
|
416
|
+
self.canvas.set_mode(mode)
|
|
417
|
+
for m,b in self.tool_buttons.items(): b.setChecked(m==mode)
|
|
418
|
+
hints={'select':'Drag to move · corner handles resize · arrow keys nudge',
|
|
419
|
+
'box':'Drag across an object to draw a box · Escape cancels',
|
|
420
|
+
'polygon':'Click vertices · Enter finishes · Escape cancels'}
|
|
421
|
+
self.canvas_hint.setText(hints[mode])
|
|
422
|
+
|
|
423
|
+
def refresh_labels(self):
|
|
424
|
+
self.guard=True; active=self.label_combo.currentText(); self.label_combo.clear(); self.classes.clear()
|
|
425
|
+
self.canvas.colors={row['name']:row['color'] for row in self.project.labels()} if self.project else {}
|
|
426
|
+
for name,color in self.canvas.colors.items():
|
|
427
|
+
self.label_combo.addItem(name)
|
|
428
|
+
item=QListWidgetItem(name); item.setFlags(item.flags()|Qt.ItemIsUserCheckable); item.setCheckState(Qt.Unchecked)
|
|
429
|
+
item.setForeground(QColor(color)); self.classes.addItem(item)
|
|
430
|
+
if active in self.canvas.colors: self.label_combo.setCurrentText(active)
|
|
431
|
+
rows=sum(self.classes.sizeHintForRow(i) for i in range(self.classes.count()))+2*self.classes.frameWidth()
|
|
432
|
+
self.classes.setFixedHeight(min(150,max(70,rows)))
|
|
433
|
+
self.canvas.label=self.label_combo.currentText(); self.guard=False; self.sync_inspector()
|
|
434
|
+
|
|
435
|
+
def add_label(self):
|
|
436
|
+
if not self.project or self.job or self.project.setting('team_remote'): return
|
|
437
|
+
name,ok=QInputDialog.getText(self,'Add label','Label name')
|
|
438
|
+
if ok and name.strip():
|
|
439
|
+
try: self.project.add_label(name,COLORS[len(self.canvas.colors)%len(COLORS)])
|
|
440
|
+
except Exception as e: self.fail('Cannot add label',e); return
|
|
441
|
+
self.refresh_labels(); self.label_combo.setCurrentText(name.strip())
|
|
442
|
+
|
|
443
|
+
def rename_label(self):
|
|
444
|
+
if not self.project or self.job or self.project.setting('team_remote') or not self.label_combo.currentText(): return
|
|
445
|
+
if self.host: self.fail('Stop hosting first','Taxonomy changes require stopping team hosting so no review changes during the rename.'); return
|
|
446
|
+
old=self.label_combo.currentText(); new,ok=QInputDialog.getText(self,'Rename label','New name · updates every annotation',text=old)
|
|
447
|
+
if not ok or new.strip()==old: return
|
|
448
|
+
if not self.save(): return
|
|
449
|
+
try: self.project.rename_label(old,new)
|
|
450
|
+
except Exception as e: self.fail('Cannot rename label',e); return
|
|
451
|
+
if self.current:
|
|
452
|
+
anns,negative,_,self.current_revision=self.project.load_document(self.current['id']); self.document=Document(anns,negative); self.canvas.set_annotations(anns)
|
|
453
|
+
self.refresh_labels(); self.refresh_controls()
|
|
454
|
+
|
|
455
|
+
def remove_label(self):
|
|
456
|
+
if not self.project or self.job or self.project.setting('team_remote') or not self.save(): return
|
|
457
|
+
if self.host: self.fail('Stop hosting first','Stop team hosting before removing a label.'); return
|
|
458
|
+
try: self.project.remove_label(self.label_combo.currentText())
|
|
459
|
+
except Exception as e: self.fail('Cannot remove label',e); return
|
|
460
|
+
self.refresh_labels()
|
|
461
|
+
|
|
462
|
+
def label_changed(self,name):
|
|
463
|
+
self.canvas.label=name
|
|
464
|
+
if self.guard or not name or not self.canvas.selection: return
|
|
465
|
+
annotations=deepcopy(self.document.annotations)
|
|
466
|
+
for a in annotations:
|
|
467
|
+
if a.id==self.canvas.selection: a.label=name
|
|
468
|
+
self.commit(annotations)
|
|
469
|
+
|
|
470
|
+
def sync_inspector(self):
|
|
471
|
+
self.guard=True
|
|
472
|
+
selected=self.canvas.selection; self.annotation_list.clear()
|
|
473
|
+
classes={a.label for a in self.document.annotations if a.kind=='classification'}
|
|
474
|
+
for i in range(self.classes.count()):
|
|
475
|
+
item=self.classes.item(i); item.setCheckState(Qt.Checked if item.text() in classes else Qt.Unchecked)
|
|
476
|
+
self.negative.setChecked(self.document.negative)
|
|
477
|
+
for i,a in enumerate(self.document.annotations,1):
|
|
478
|
+
if a.kind=='classification': continue
|
|
479
|
+
item=QListWidgetItem(f'{i:02d} {a.label}\n{a.kind.title()}'+(f' · {len(a.geometry)} vertices' if a.kind=='polygon' else ''))
|
|
480
|
+
item.setData(Qt.UserRole,a.id); self.annotation_list.addItem(item)
|
|
481
|
+
if a.id==selected: self.annotation_list.setCurrentItem(item)
|
|
482
|
+
self.guard=False; self.selection_changed(selected)
|
|
483
|
+
self.annotation_list.setFixedHeight(min(160,max(70,self.annotation_list.count()*48)))
|
|
484
|
+
|
|
485
|
+
def class_focused(self,item,previous=None):
|
|
486
|
+
if self.guard or not item or not self.project: return
|
|
487
|
+
if self.project.setting('tasks')==['classification']:
|
|
488
|
+
self.guard=True; self.label_combo.setCurrentText(item.text()); self.guard=False
|
|
489
|
+
|
|
490
|
+
def class_changed(self,item):
|
|
491
|
+
if self.guard: return
|
|
492
|
+
annotations=[a for a in self.document.annotations if not(a.kind=='classification' and a.label==item.text())]
|
|
493
|
+
if item.checkState()==Qt.Checked: annotations.append(Annotation(item.text(),'classification'))
|
|
494
|
+
self.commit(annotations)
|
|
495
|
+
|
|
496
|
+
def negative_changed(self,checked):
|
|
497
|
+
if self.guard: return
|
|
498
|
+
if checked and self.document.annotations:
|
|
499
|
+
if QMessageBox.question(self,'Mark no targets','Clear annotations and mark this image as containing no targets? This can be undone.')!=QMessageBox.Yes:
|
|
500
|
+
self.sync_inspector(); return
|
|
501
|
+
self.commit([] if checked else self.document.annotations,checked)
|
|
502
|
+
|
|
503
|
+
def annotation_selected(self,item,previous=None):
|
|
504
|
+
if not self.guard: self.canvas.select(item.data(Qt.UserRole) if item else None)
|
|
505
|
+
|
|
506
|
+
def selection_changed(self,ident):
|
|
507
|
+
a=next((x for x in self.document.annotations if x.id==ident),None)
|
|
508
|
+
self.guard=True
|
|
509
|
+
if a:
|
|
510
|
+
self.label_combo.setCurrentText(a.label)
|
|
511
|
+
self.selection_info.setText(f'{a.kind.title()} · {a.id[:8]}')
|
|
512
|
+
for i in range(self.annotation_list.count()):
|
|
513
|
+
if self.annotation_list.item(i).data(Qt.UserRole)==ident: self.annotation_list.setCurrentRow(i)
|
|
514
|
+
if a.kind=='box':
|
|
515
|
+
for box,value in zip(self.coordinates,a.geometry): box.setValue(value*100)
|
|
516
|
+
else: self.selection_info.setText('Select an object to edit its geometry.')
|
|
517
|
+
self.geometry_widget.setVisible(a is not None and a.kind=='box')
|
|
518
|
+
self.guard=False; self.refresh_controls()
|
|
519
|
+
|
|
520
|
+
def geometry_changed(self):
|
|
521
|
+
if self.guard or not self.canvas.selection: return
|
|
522
|
+
annotations=deepcopy(self.document.annotations)
|
|
523
|
+
for a in annotations:
|
|
524
|
+
if a.id==self.canvas.selection and a.kind=='box': a.geometry=[w.value()/100 for w in self.coordinates]
|
|
525
|
+
self.commit(annotations); self.selection_changed(self.canvas.selection)
|
|
526
|
+
|
|
527
|
+
def delete_selected(self):
|
|
528
|
+
if self.canvas.selection: self.commit([a for a in self.document.annotations if a.id!=self.canvas.selection])
|
|
529
|
+
|
|
530
|
+
def duplicate(self):
|
|
531
|
+
a=next((deepcopy(x) for x in self.document.annotations if x.id==self.canvas.selection),None)
|
|
532
|
+
if a:
|
|
533
|
+
a.id=str(uuid.uuid4()); self.commit(self.document.annotations+[a]); self.canvas.select(a.id)
|
|
534
|
+
|
|
535
|
+
def clear_annotations(self):
|
|
536
|
+
if not self.current: return
|
|
537
|
+
if QMessageBox.question(self,'Clear annotations','Clear this image and return it to unlabeled? You can undo this action.')==QMessageBox.Yes:
|
|
538
|
+
self.canvas.cancel(); self.commit([])
|
|
539
|
+
|
|
540
|
+
def nudge(self,key,amount):
|
|
541
|
+
a=next((deepcopy(x) for x in self.document.annotations if x.id==self.canvas.selection),None)
|
|
542
|
+
if not a or a.kind=='classification': return
|
|
543
|
+
dx,dy={Qt.Key_Left:(-amount,0),Qt.Key_Right:(amount,0),Qt.Key_Up:(0,-amount),Qt.Key_Down:(0,amount)}[key]
|
|
544
|
+
pts=self.canvas.vertices(a)
|
|
545
|
+
dx=max(-min(p[0] for p in pts),min(dx,1-max(p[0] for p in pts)))
|
|
546
|
+
dy=max(-min(p[1] for p in pts),min(dy,1-max(p[1] for p in pts)))
|
|
547
|
+
if a.kind=='box': a.geometry=[a.geometry[0]+dx,a.geometry[1]+dy,a.geometry[2]+dx,a.geometry[3]+dy]
|
|
548
|
+
else: a.geometry=[[x+dx,y+dy] for x,y in a.geometry]
|
|
549
|
+
self.commit([a if x.id==a.id else x for x in self.document.annotations])
|
|
550
|
+
|
|
551
|
+
def opacity_changed(self,value): self.canvas.opacity=value/100; self.canvas.update(); self.settings.setValue('opacity',value)
|
|
552
|
+
|
|
553
|
+
def import_folder(self):
|
|
554
|
+
if not self.project or self.job: return
|
|
555
|
+
path=QFileDialog.getExistingDirectory(self,'Import image folder')
|
|
556
|
+
if path: self.start_import([path])
|
|
557
|
+
|
|
558
|
+
def import_files(self):
|
|
559
|
+
if not self.project or self.job: return
|
|
560
|
+
paths,_=QFileDialog.getOpenFileNames(self,'Import images','','Images (*.jpg *.jpeg *.png *.webp)')
|
|
561
|
+
if paths: self.start_import(paths)
|
|
562
|
+
|
|
563
|
+
def start_import(self,paths):
|
|
564
|
+
if self.project.setting('team_remote'): self.notice('Import images on the project host.'); return
|
|
565
|
+
root=self.project.root
|
|
566
|
+
self.start_job('import',lambda cancel,progress:import_images(root,paths,cancel,progress))
|
|
567
|
+
|
|
568
|
+
def import_annotation_dialog(self):
|
|
569
|
+
if not self.project or self.job or self.project.setting('team_remote'): return
|
|
570
|
+
path,_=QFileDialog.getOpenFileName(self,'Import Labelme JSON or native annotations.jsonl','','Annotations (*.json *.jsonl)')
|
|
571
|
+
if path and self.save():
|
|
572
|
+
root=self.project.root; self.start_job('annotations',lambda cancel,progress:import_annotations(root,path,cancel,progress))
|
|
573
|
+
|
|
574
|
+
def start_job(self,kind,function):
|
|
575
|
+
if self.job: return
|
|
576
|
+
self.job_kind=kind; self.job=Job(function)
|
|
577
|
+
self.job.events.progress.connect(self.job_progress); self.job.events.result.connect(self.job_result)
|
|
578
|
+
self.job.events.error.connect(self.job_error); self.job.events.finished.connect(self.job_finished)
|
|
579
|
+
self.progress.setRange(0,0); self.progress.show(); self.cancel_button.show(); self.refresh_controls()
|
|
580
|
+
self.notice(f'{kind.title()} running…'); self.pool.start(self.job)
|
|
581
|
+
|
|
582
|
+
@Slot(int,str)
|
|
583
|
+
def job_progress(self,value,message):
|
|
584
|
+
if self.job_kind=='export': self.progress.setRange(0,100); self.progress.setValue(value)
|
|
585
|
+
self.notice(message)
|
|
586
|
+
|
|
587
|
+
@Slot(object)
|
|
588
|
+
def job_result(self,result):
|
|
589
|
+
if self.job_kind in ('import','annotations'):
|
|
590
|
+
self.refresh_images(); self.refresh_labels()
|
|
591
|
+
text=f"Imported {result['added']:,} images"+(' · cancelled' if result.get('cancelled') else '')
|
|
592
|
+
if result.get('errors'): self.show_report('Import summary',text+'\n\n'+'\n'.join(result['errors']))
|
|
593
|
+
self.notice(text)
|
|
594
|
+
elif self.job_kind=='export':
|
|
595
|
+
self.show_report('Export summary',f"Included: {result['included']}\nUnlabeled included: {result['unlabeled']}\nSkipped images: {result['skipped']}\nAnnotations unsupported by this format: {result['unsupported_annotations']}\nCancelled: {result.get('cancelled',False)}\n\n{result['path']}\n\n"+'\n'.join(result['errors']))
|
|
596
|
+
elif self.job_kind=='analysis': self.analysis_result(result)
|
|
597
|
+
elif self.job_kind=='backup': self.notice(f'Backup saved: {result}')
|
|
598
|
+
elif self.job_kind=='join': self.pending_team=result
|
|
599
|
+
elif self.job_kind=='download':
|
|
600
|
+
token,path=result
|
|
601
|
+
if token==self.token: self.loader.request(token,path)
|
|
602
|
+
elif self.current and self.canvas.image is None: self.pending_image_retry=True
|
|
603
|
+
elif self.job_kind=='resolve':
|
|
604
|
+
self.reload_current(); self.notice('Conflict resolved; project history retains the save.')
|
|
605
|
+
|
|
606
|
+
@Slot(str)
|
|
607
|
+
def job_error(self,error):
|
|
608
|
+
if self.job_kind=='download': self.image_failed(self.token,error)
|
|
609
|
+
else: self.fail('Background operation failed',error)
|
|
610
|
+
|
|
611
|
+
@Slot()
|
|
612
|
+
def job_finished(self):
|
|
613
|
+
self.job=None; self.job_kind=''; self.progress.hide(); self.cancel_button.hide(); self.refresh_controls()
|
|
614
|
+
if self.pending_team:
|
|
615
|
+
root,client=self.pending_team; self.pending_team=None; self.load_project(root,client=client)
|
|
616
|
+
elif getattr(self,'pending_image_retry',False):
|
|
617
|
+
self.pending_image_retry=False; self.select_index(self.images.currentIndex())
|
|
618
|
+
|
|
619
|
+
def cancel_job(self):
|
|
620
|
+
if self.job: self.job.cancel.set(); self.notice('Cancelling after the current file…')
|
|
621
|
+
|
|
622
|
+
def export_dialog(self):
|
|
623
|
+
if not self.project or self.job or not self.save(): return
|
|
624
|
+
format_name,ok=QInputDialog.getItem(self,'Export annotations','Editable local export format',
|
|
625
|
+
['Labelme JSON','YOLO boxes','COCO JSON','Classification CSV','Native JSONL'],0,False)
|
|
626
|
+
if not ok: return
|
|
627
|
+
path=QFileDialog.getExistingDirectory(self,'Choose export destination')
|
|
628
|
+
if path:
|
|
629
|
+
root=self.project.root; self.start_job('export',lambda cancel,progress:export_project(root,path,format_name,cancel,progress))
|
|
630
|
+
|
|
631
|
+
def show_analysis(self):
|
|
632
|
+
if self.project and not self.job and self.save():
|
|
633
|
+
root=self.project.root; self.start_job('analysis',lambda cancel,progress:analyze(root,cancel,progress))
|
|
634
|
+
|
|
635
|
+
def analysis_result(self,result):
|
|
636
|
+
dialog=QDialog(self); dialog.setWindowTitle('Dataset analysis'); dialog.resize(760,620)
|
|
637
|
+
layout=QVBoxLayout(dialog); layout.addWidget(label('Dataset overview','brand'))
|
|
638
|
+
layout.addWidget(label(f"{result['total']:,} images · {result['labeled']:,} labeled · {result['unlabeled']:,} unlabeled · {result['negative']:,} no targets"))
|
|
639
|
+
bar=QProgressBar(); bar.setRange(0,max(1,result['total'])); bar.setValue(result['labeled']+result['negative']); layout.addWidget(bar)
|
|
640
|
+
layout.addWidget(label(f"{result['boxes']} boxes · {result['polygons']} polygons · {result['classes']} classes · {result['drafts']} recovery drafts"))
|
|
641
|
+
table=QTableWidget(len(result['distribution']),2); table.setHorizontalHeaderLabels(['Label','Annotation count']); table.horizontalHeader().setStretchLastSection(True)
|
|
642
|
+
table.setEditTriggers(QTableWidget.NoEditTriggers)
|
|
643
|
+
for i,(name,count) in enumerate(sorted(result['distribution'].items(),key=lambda x:-x[1])):
|
|
644
|
+
table.setItem(i,0,QTableWidgetItem(name)); table.setItem(i,1,QTableWidgetItem(str(count)))
|
|
645
|
+
layout.addWidget(table); issues=QTextEdit(); issues.setReadOnly(True); issues.setPlainText('\n'.join(result['problems']) or 'No annotation-validation problems or missing images found.'); layout.addWidget(issues)
|
|
646
|
+
layout.addWidget(button('Close',dialog.accept)); dialog.exec()
|
|
647
|
+
|
|
648
|
+
def show_report(self,title,text):
|
|
649
|
+
dialog=QDialog(self); dialog.setWindowTitle(title); dialog.resize(720,460); layout=QVBoxLayout(dialog)
|
|
650
|
+
report=QTextEdit(); report.setReadOnly(True); report.setPlainText(text); layout.addWidget(report)
|
|
651
|
+
layout.addWidget(button('Close',dialog.accept)); dialog.exec()
|
|
652
|
+
|
|
653
|
+
def backup_dialog(self):
|
|
654
|
+
if not self.project or self.job or not self.save(): return
|
|
655
|
+
path,_=QFileDialog.getSaveFileName(self,'Backup project database',str(self.project.root.parent/(self.project.root.name+'.sqlite3')),'Project backup (*.sqlite3)')
|
|
656
|
+
if not path: return
|
|
657
|
+
root=self.project.root
|
|
658
|
+
def backup(cancel,progress):
|
|
659
|
+
project=Project(root,create=False)
|
|
660
|
+
try: project.backup(path); return path
|
|
661
|
+
finally: project.close()
|
|
662
|
+
self.start_job('backup',backup)
|
|
663
|
+
|
|
664
|
+
def restore_dialog(self):
|
|
665
|
+
if self.job or not self.save(): return
|
|
666
|
+
path,_=QFileDialog.getOpenFileName(self,'Choose backup','','Project backup (*.sqlite3)')
|
|
667
|
+
if not path: return
|
|
668
|
+
folder=QFileDialog.getExistingDirectory(self,'Restore into a new project folder')
|
|
669
|
+
if not folder: return
|
|
670
|
+
try: restored=Project.restore(path,folder); restored.close()
|
|
671
|
+
except Exception as e: self.fail('Restore failed',e); return
|
|
672
|
+
self.load_project(folder)
|
|
673
|
+
|
|
674
|
+
def relink_dialog(self):
|
|
675
|
+
if not self.current or not self.save(): return
|
|
676
|
+
path,_=QFileDialog.getOpenFileName(self,'Locate this image','','Images (*.jpg *.jpeg *.png *.webp)')
|
|
677
|
+
if not path: return
|
|
678
|
+
try:
|
|
679
|
+
from PIL import Image
|
|
680
|
+
with Image.open(path) as im:
|
|
681
|
+
w,h=im.size
|
|
682
|
+
if im.getexif().get(274,1) in (5,6,7,8): w,h=h,w
|
|
683
|
+
self.project.relink(self.current['id'],path,w,h)
|
|
684
|
+
self.current=dict(self.project.image(self.current['id'])); self.token+=1; self.loading=True
|
|
685
|
+
self.loader.request(self.token,path); self.model.invalidate()
|
|
686
|
+
except Exception as e: self.fail('Relink failed',e)
|
|
687
|
+
|
|
688
|
+
def task_settings(self):
|
|
689
|
+
if not self.project or not self.save(): return
|
|
690
|
+
if self.project.setting('team_remote') or self.host:
|
|
691
|
+
self.notice('Pipeline settings are managed on the host while team hosting is stopped.'); return
|
|
692
|
+
dialog=QDialog(self); dialog.setWindowTitle('Project tasks'); layout=QVBoxLayout(dialog)
|
|
693
|
+
layout.addWidget(label('Enable any combination of tasks. Existing annotations are retained.'))
|
|
694
|
+
tasks=self.project.setting('tasks',['box','polygon','classification']); boxes={}
|
|
695
|
+
for key,title in [('box','Bounding-box detection'),('polygon','Polygon segmentation'),('classification','Multi-label classification')]:
|
|
696
|
+
box=QCheckBox(title); box.setChecked(key in tasks); boxes[key]=box; layout.addWidget(box)
|
|
697
|
+
buttons=QDialogButtonBox(QDialogButtonBox.Save|QDialogButtonBox.Cancel); buttons.accepted.connect(dialog.accept); buttons.rejected.connect(dialog.reject); layout.addWidget(buttons)
|
|
698
|
+
if dialog.exec()==QDialog.Accepted:
|
|
699
|
+
tasks=[key for key,box in boxes.items() if box.isChecked()]
|
|
700
|
+
if not tasks: self.fail('Choose a pipeline','Enable at least one task.'); return
|
|
701
|
+
used={row[0] for row in self.project.conn.execute("SELECT DISTINCT json_extract(data,'$.kind') FROM annotations")}
|
|
702
|
+
if used-set(tasks): self.fail('Existing annotations use this task','Export or remove those annotations before disabling their task.'); return
|
|
703
|
+
self.project.set_setting('tasks',tasks); self.set_tool('select'); self.refresh_controls()
|
|
704
|
+
|
|
705
|
+
def toggle_focus(self):
|
|
706
|
+
self.focus_mode=not self.focus_mode; self.left.setVisible(not self.focus_mode); self.right.setVisible(not self.focus_mode)
|
|
707
|
+
|
|
708
|
+
def join_team(self):
|
|
709
|
+
if self.job or self.sync_job or self.host or not self.save(): return
|
|
710
|
+
dialog=QDialog(self); dialog.setWindowTitle('Join a team project'); dialog.resize(580,420)
|
|
711
|
+
layout=QVBoxLayout(dialog); layout.addWidget(label('Connect to your project host','cardTitle'))
|
|
712
|
+
url=QLineEdit(); url.setPlaceholderText('http://192.168.1.20:8765 or https://your-host')
|
|
713
|
+
token=QLineEdit(); token.setPlaceholderText('Your reviewer access token'); token.setEchoMode(QLineEdit.Password)
|
|
714
|
+
folder=QLineEdit(); folder.setPlaceholderText('Local project cache folder')
|
|
715
|
+
for title,widget in [('Host address',url),('Access token',token),('Local cache',folder)]: layout.addWidget(label(title)); layout.addWidget(widget)
|
|
716
|
+
if self.project and self.project.setting('team_remote'):
|
|
717
|
+
url.setText(self.project.setting('team_remote')); folder.setText(str(self.project.root))
|
|
718
|
+
def browse():
|
|
719
|
+
path=QFileDialog.getExistingDirectory(dialog,'Choose a private local project cache folder')
|
|
720
|
+
if path: folder.setText(path)
|
|
721
|
+
layout.addWidget(button('Choose local folder…',browse))
|
|
722
|
+
info=label('Images are downloaded from your chosen host as you open them. Tokens stay in memory. Use HTTPS or a trusted LAN/VPN.'); info.setWordWrap(True); layout.addWidget(info)
|
|
723
|
+
buttons=QDialogButtonBox(QDialogButtonBox.Cancel); buttons.addButton('Connect',QDialogButtonBox.AcceptRole)
|
|
724
|
+
buttons.accepted.connect(dialog.accept); buttons.rejected.connect(dialog.reject); layout.addWidget(buttons)
|
|
725
|
+
if dialog.exec()!=QDialog.Accepted: return
|
|
726
|
+
try:
|
|
727
|
+
client=TeamClient(url.text().strip(),token.text().strip())
|
|
728
|
+
if not token.text().strip() or not folder.text().strip(): raise ValueError('Enter your token and a local cache folder')
|
|
729
|
+
root=Path(folder.text()).resolve()
|
|
730
|
+
if (root/'project.sqlite3').exists():
|
|
731
|
+
check=Project(root,create=False)
|
|
732
|
+
try:
|
|
733
|
+
if check.setting('team_remote')!=client.url: raise ValueError('Choose a new cache folder or the cache for this host')
|
|
734
|
+
finally: check.close()
|
|
735
|
+
elif root.exists() and any(root.iterdir()): raise ValueError('Use an empty folder for a new team cache')
|
|
736
|
+
except Exception as e: self.fail('Cannot connect',e); return
|
|
737
|
+
def connect(cancel,progress):
|
|
738
|
+
# Authenticate before creating any cache.
|
|
739
|
+
client.request('/meta')
|
|
740
|
+
project=Project(root); project.set_setting('team_remote',client.url); project.close()
|
|
741
|
+
sync_replica(root,client,cancel,progress)
|
|
742
|
+
return root,client
|
|
743
|
+
self.start_job('join',connect)
|
|
744
|
+
|
|
745
|
+
def sync_team(self):
|
|
746
|
+
if not self.team_client:
|
|
747
|
+
if self.project and self.project.setting('team_remote'): self.join_team()
|
|
748
|
+
return
|
|
749
|
+
if self.sync_job or self.job or not self.project: return
|
|
750
|
+
if not self.save(): return
|
|
751
|
+
root=self.project.root; client=self.team_client
|
|
752
|
+
self.sync_job=Job(lambda cancel,progress:sync_replica(root,client,cancel,progress))
|
|
753
|
+
self.sync_job.events.result.connect(self.sync_result); self.sync_job.events.error.connect(self.sync_error)
|
|
754
|
+
self.sync_job.events.finished.connect(self.sync_finished)
|
|
755
|
+
self.sync_button.setText('Syncing…'); self.pool.start(self.sync_job)
|
|
756
|
+
|
|
757
|
+
@Slot(object)
|
|
758
|
+
def sync_result(self,result):
|
|
759
|
+
current_id=self.current['id'] if self.current else None
|
|
760
|
+
# Do not reset list selection on every timer tick.
|
|
761
|
+
if self.model.total!=self.project.query(self.search.text(),self.filter.currentData(),count=True): self.refresh_images()
|
|
762
|
+
else: self.model.invalidate()
|
|
763
|
+
self.refresh_labels()
|
|
764
|
+
if current_id in result['changed'] and not self.dirty: self.reload_current()
|
|
765
|
+
if result['conflicts']:
|
|
766
|
+
self.notice(f"{len(result['conflicts'])} image conflict(s). Open Team → resolve to choose a version.")
|
|
767
|
+
self.sync_button.setText('Resolve conflicts')
|
|
768
|
+
else:
|
|
769
|
+
queued=self.project.conn.execute('SELECT COUNT(*) FROM team_links WHERE dirty=1').fetchone()[0]
|
|
770
|
+
self.notice('Team synced · '+result['meta']['reviewer'])
|
|
771
|
+
if not self.dirty: self.set_save_state('Queued for sync' if queued else 'Synced to team')
|
|
772
|
+
|
|
773
|
+
@Slot(str)
|
|
774
|
+
def sync_error(self,error):
|
|
775
|
+
self.notice(f'Team offline / sync failed: {error}. Local work is retained.')
|
|
776
|
+
self.sync_button.setText('Retry sync')
|
|
777
|
+
|
|
778
|
+
@Slot()
|
|
779
|
+
def sync_finished(self):
|
|
780
|
+
self.sync_job=None
|
|
781
|
+
if self.sync_button.text()=='Syncing…': self.sync_button.setText('Sync')
|
|
782
|
+
|
|
783
|
+
def reload_current(self):
|
|
784
|
+
if not self.current: return
|
|
785
|
+
anns,negative,recovered,revision=self.project.load_document(self.current['id'])
|
|
786
|
+
self.current=dict(self.project.image(self.current['id']))
|
|
787
|
+
self.current_revision=revision
|
|
788
|
+
self.document=Document(anns,negative); self.dirty=recovered
|
|
789
|
+
self.canvas.set_annotations(anns); self.sync_inspector(); self.model.invalidate()
|
|
790
|
+
|
|
791
|
+
def team_dialog(self):
|
|
792
|
+
if not self.project: return
|
|
793
|
+
if self.project.setting('team_remote'):
|
|
794
|
+
self.team_client_dialog(); return
|
|
795
|
+
dialog=QDialog(self); dialog.setWindowTitle('Team workspace'); dialog.resize(620,560)
|
|
796
|
+
layout=QVBoxLayout(dialog); layout.addWidget(label('Work together, on your host.','cardTitle'))
|
|
797
|
+
if self.current and self.dirty:
|
|
798
|
+
latest=self.project.conn.execute('SELECT revision FROM revisions WHERE image_id=?',(self.current['id'],)).fetchone()[0]
|
|
799
|
+
if latest!=self.current_revision:
|
|
800
|
+
layout.addWidget(label('The current image changed on the team while you were editing.'))
|
|
801
|
+
layout.addWidget(button('Resolve my current image…',lambda:(dialog.accept(),self.resolve_owner_conflict())))
|
|
802
|
+
info=label('This computer owns the project. Teammates connect with individual reviewer tokens. Stop hosting to disconnect access.'); info.setWordWrap(True); layout.addWidget(info)
|
|
803
|
+
host=QLineEdit('0.0.0.0'); host.setPlaceholderText('Listen address'); port=QLineEdit('8765')
|
|
804
|
+
row=QHBoxLayout(); row.addWidget(label('Listen')); row.addWidget(host); row.addWidget(label('Port')); row.addWidget(port); layout.addLayout(row)
|
|
805
|
+
status=label('Hosting is stopped'); layout.addWidget(status)
|
|
806
|
+
def toggle():
|
|
807
|
+
if self.host:
|
|
808
|
+
self.host.stop(); self.host=None; status.setText('Hosting stopped'); start.setText('Start hosting'); return
|
|
809
|
+
if not self.save(): return
|
|
810
|
+
try:
|
|
811
|
+
self.host=TeamServer(self.project.root,host.text().strip(),int(port.text())).start()
|
|
812
|
+
self.project.set_setting('mode','team')
|
|
813
|
+
status.setText(f'Listening on port {self.host.server_address[1]}. Share your LAN IP with teammates.')
|
|
814
|
+
start.setText('Stop hosting')
|
|
815
|
+
except Exception as e: self.fail('Could not start host',e)
|
|
816
|
+
start=button('Stop hosting' if self.host else 'Start hosting',toggle,True); layout.addWidget(start)
|
|
817
|
+
if self.host: status.setText(f'Hosting on port {self.host.server_address[1]}')
|
|
818
|
+
layout.addWidget(label('REVIEWERS','section')); members=QListWidget(); layout.addWidget(members)
|
|
819
|
+
def refresh():
|
|
820
|
+
members.clear()
|
|
821
|
+
for member in self.project.conn.execute('SELECT id,name,active FROM team_members ORDER BY name'):
|
|
822
|
+
item=QListWidgetItem(member['name']+('' if member['active'] else ' · revoked')); item.setData(Qt.UserRole,member['id']); members.addItem(item)
|
|
823
|
+
def invite():
|
|
824
|
+
name,ok=QInputDialog.getText(dialog,'Invite reviewer','Reviewer name')
|
|
825
|
+
if not ok: return
|
|
826
|
+
try: access=add_member(self.project.root,name)
|
|
827
|
+
except Exception as e: self.fail('Cannot invite',e); return
|
|
828
|
+
token_dialog=QDialog(dialog); token_dialog.setWindowTitle('Reviewer access token'); token_layout=QVBoxLayout(token_dialog)
|
|
829
|
+
token_layout.addWidget(label('Share privately with this reviewer. It is displayed only now.'))
|
|
830
|
+
field=QLineEdit(access); field.setReadOnly(True); field.setMinimumWidth(440); token_layout.addWidget(field)
|
|
831
|
+
token_layout.addWidget(button('Close',token_dialog.accept)); token_dialog.exec(); refresh()
|
|
832
|
+
def revoke():
|
|
833
|
+
item=members.currentItem()
|
|
834
|
+
if not item: return
|
|
835
|
+
if QMessageBox.question(dialog,'Revoke access','Revoke this reviewer token? Their existing work remains in the project.')!=QMessageBox.Yes: return
|
|
836
|
+
with self.project.conn: self.project.conn.execute('UPDATE team_members SET active=0 WHERE id=?',(item.data(Qt.UserRole),))
|
|
837
|
+
refresh()
|
|
838
|
+
row=QHBoxLayout(); row.addWidget(button('Invite reviewer',invite)); row.addWidget(button('Revoke selected',revoke)); layout.addLayout(row); refresh()
|
|
839
|
+
note=label('Built-in desktop hosting uses HTTP on a trusted LAN/VPN. For TLS, use the CLI host command with a certificate.'); note.setWordWrap(True); layout.addWidget(note)
|
|
840
|
+
layout.addWidget(button('Done',dialog.accept)); dialog.exec()
|
|
841
|
+
|
|
842
|
+
def resolve_owner_conflict(self):
|
|
843
|
+
if not self.current: return
|
|
844
|
+
box=QMessageBox(self); box.setWindowTitle('Resolve changed image')
|
|
845
|
+
box.setText('The team saved a newer version. Choose which version to keep. Previous saved versions remain in project history.')
|
|
846
|
+
mine=box.addButton('Publish my version',QMessageBox.AcceptRole)
|
|
847
|
+
theirs=box.addButton('Use team version',QMessageBox.DestructiveRole)
|
|
848
|
+
box.addButton(QMessageBox.Cancel); box.exec()
|
|
849
|
+
if box.clickedButton()==mine:
|
|
850
|
+
self.current_revision=self.project.conn.execute('SELECT revision FROM revisions WHERE image_id=?',(self.current['id'],)).fetchone()[0]
|
|
851
|
+
self.save()
|
|
852
|
+
elif box.clickedButton()==theirs:
|
|
853
|
+
with self.project.conn: self.project.conn.execute('DELETE FROM drafts WHERE image_id=?',(self.current['id'],))
|
|
854
|
+
self.reload_current(); self.set_save_state('Saved')
|
|
855
|
+
|
|
856
|
+
def team_client_dialog(self):
|
|
857
|
+
dialog=QDialog(self); dialog.setWindowTitle('Team sync & conflicts'); dialog.resize(630,480); layout=QVBoxLayout(dialog)
|
|
858
|
+
layout.addWidget(label(self.project.setting('name',self.project.root.name),'cardTitle'))
|
|
859
|
+
layout.addWidget(label(self.project.setting('team_remote')))
|
|
860
|
+
conflicts=QListWidget(); layout.addWidget(conflicts)
|
|
861
|
+
for row in self.project.conn.execute('SELECT images.id,images.path FROM team_links JOIN images ON images.id=team_links.image_id WHERE conflict=1'):
|
|
862
|
+
item=QListWidgetItem(Path(row['path']).name); item.setData(Qt.UserRole,row['id']); conflicts.addItem(item)
|
|
863
|
+
if not conflicts.count(): layout.addWidget(label('No conflicts. Each saved image is synchronized in the background.'))
|
|
864
|
+
layout.addWidget(button('Reconnect with reviewer token…',lambda:(dialog.accept(),self.join_team())))
|
|
865
|
+
def resolve(keep):
|
|
866
|
+
if self.sync_job or self.job: self.notice('Wait for the current sync to finish.'); return
|
|
867
|
+
if not self.team_client or not conflicts.currentItem(): return
|
|
868
|
+
ident=conflicts.currentItem().data(Qt.UserRole)
|
|
869
|
+
message='Publish your local version over the current team version?' if keep else 'Use the team version? Your local conflicting version will be replaced.'
|
|
870
|
+
if QMessageBox.question(dialog,'Resolve conflict',message)!=QMessageBox.Yes: return
|
|
871
|
+
root=self.project.root; client=self.team_client
|
|
872
|
+
dialog.accept()
|
|
873
|
+
self.start_job('resolve',lambda cancel,progress:resolve_conflict(root,ident,client,keep))
|
|
874
|
+
row=QHBoxLayout(); row.addWidget(button('Use team version',lambda:resolve(False))); row.addWidget(button('Publish my version',lambda:resolve(True))); layout.addLayout(row)
|
|
875
|
+
layout.addWidget(button('Close',dialog.accept)); dialog.exec()
|
|
876
|
+
|
|
877
|
+
def toggle_theme(self):
|
|
878
|
+
self.dark=not self.dark; self.settings.setValue('dark',self.dark); apply_theme(QApplication.instance(),self.dark)
|
|
879
|
+
|
|
880
|
+
def preferences(self):
|
|
881
|
+
dialog=QDialog(self); dialog.setWindowTitle('Keyboard & preferences'); dialog.resize(530,700); layout=QVBoxLayout(dialog)
|
|
882
|
+
autosave=QCheckBox('Autosave after 700 ms · recovery drafts are always written'); autosave.setChecked(self.settings.value('autosave',True,type=bool)); layout.addWidget(autosave)
|
|
883
|
+
layout.addWidget(label('Space + drag pans. Arrow keys nudge; Shift increases the step.'))
|
|
884
|
+
table=QTableWidget(len(self.bindings),2); table.setHorizontalHeaderLabels(['Command','Shortcut']); table.horizontalHeader().setStretchLastSection(True); fields={}
|
|
885
|
+
for i,(key,value) in enumerate(self.bindings.items()):
|
|
886
|
+
item=QTableWidgetItem(key.replace('_',' ').title()); item.setFlags(item.flags() & ~Qt.ItemIsEditable); table.setItem(i,0,item)
|
|
887
|
+
editor=QKeySequenceEdit(QKeySequence(value)); editor.setMaximumSequenceLength(1); table.setCellWidget(i,1,editor); fields[key]=editor
|
|
888
|
+
layout.addWidget(table)
|
|
889
|
+
layout.addWidget(button('Toggle light / dark',self.toggle_theme))
|
|
890
|
+
buttons=QDialogButtonBox(QDialogButtonBox.Save|QDialogButtonBox.Cancel); layout.addWidget(buttons)
|
|
891
|
+
def accept():
|
|
892
|
+
bindings={k:v.keySequence().toString() for k,v in fields.items()}
|
|
893
|
+
values=[v for v in bindings.values() if v]
|
|
894
|
+
if len(values)!=len(set(values)): QMessageBox.warning(dialog,'Shortcut conflict','Choose unique shortcuts.'); return
|
|
895
|
+
self.bindings=bindings; self.settings.setValue('keys',bindings); self.settings.setValue('autosave',autosave.isChecked()); self.update_action_labels(); dialog.accept()
|
|
896
|
+
buttons.accepted.connect(accept); buttons.rejected.connect(dialog.reject); dialog.exec()
|
|
897
|
+
|
|
898
|
+
def closeEvent(self,event):
|
|
899
|
+
if self.sync_job:
|
|
900
|
+
self.sync_job.cancel.set(); self.notice('Waiting for the current team request to finish.'); event.ignore(); return
|
|
901
|
+
if self.job or self.loader.active:
|
|
902
|
+
self.cancel_job(); self.loader.pending=None
|
|
903
|
+
self.notice('Finishing the active file operation. Close again when it has stopped.'); event.ignore(); return
|
|
904
|
+
if not self.save():
|
|
905
|
+
self.fail('Cannot close safely','Saving failed. Your edits remain in this window. Resolve disk space or permissions and try Save again.'); event.ignore(); return
|
|
906
|
+
self.settings.setValue('geometry',self.saveGeometry()); self.settings.setValue('splitter',self.splitter.saveState())
|
|
907
|
+
self.sync_timer.stop()
|
|
908
|
+
if self.host: self.host.stop(); self.host=None
|
|
909
|
+
if self.project: self.project.close(); self.project=None
|
|
910
|
+
QApplication.instance().removeEventFilter(self); event.accept()
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def main():
|
|
914
|
+
app=QApplication(sys.argv); app.setOrganizationName('Lannotex'); app.setApplicationName('Lannotex Desktop')
|
|
915
|
+
app.setStyle('Fusion')
|
|
916
|
+
window=Main(); window.show()
|
|
917
|
+
# A bounded test must actually enter the Qt event loop and report completion.
|
|
918
|
+
if '--smoke-test' in sys.argv:
|
|
919
|
+
def done():
|
|
920
|
+
target=os.environ.get('LANNOTEX_SMOKE_RESULT')
|
|
921
|
+
if target: atomic_text(target,json.dumps({'version':__version__,'window_visible':window.isVisible(),'qt_event_loop':True}))
|
|
922
|
+
window.close()
|
|
923
|
+
QTimer.singleShot(300,done)
|
|
924
|
+
raise SystemExit(app.exec())
|