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/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())