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/team.py ADDED
@@ -0,0 +1,248 @@
1
+ """Explicit, authenticated team hosting. SQLite is accessed only on the host.
2
+
3
+ Desktop clients keep private local replicas and an outbox. HTTP work runs in jobs,
4
+ never the Qt UI thread. Plain HTTP is for trusted LAN/VPN; CLI supports TLS.
5
+ """
6
+ from __future__ import annotations
7
+ import hashlib
8
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ import secrets
13
+ import ssl
14
+ import tempfile
15
+ import threading
16
+ from urllib.error import HTTPError
17
+ from urllib.parse import parse_qs, urlparse
18
+ from urllib.request import Request, urlopen
19
+ import uuid
20
+ from . import __version__
21
+ from .domain import Annotation, validate_all
22
+ from .storage import ConflictError, Project
23
+
24
+
25
+ def add_member(root, name):
26
+ if not name.strip() or len(name)>120: raise ValueError('Enter a reviewer name of 1–120 characters')
27
+ token=secrets.token_urlsafe(32); project=Project(root,create=False)
28
+ try:
29
+ with project.conn:
30
+ project.conn.execute('INSERT INTO team_members(id,name,token_hash) VALUES(?,?,?)',
31
+ (uuid.uuid4().hex,name.strip(),hashlib.sha256(token.encode()).hexdigest()))
32
+ return token
33
+ finally: project.close()
34
+
35
+
36
+ class TeamServer(ThreadingHTTPServer):
37
+ daemon_threads=True
38
+ def __init__(self, root, host='127.0.0.1', port=8765, cert=None, key=None):
39
+ self.root=Path(root).resolve()
40
+ project=Project(self.root,create=False)
41
+ if not project.setting('project_id'): project.set_setting('project_id',str(uuid.uuid4()))
42
+ project.close()
43
+ super().__init__((host,port),TeamHandler)
44
+ if cert:
45
+ context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER); context.load_cert_chain(cert,key)
46
+ self.socket=context.wrap_socket(self.socket,server_side=True)
47
+ self.thread=None
48
+
49
+ def start(self):
50
+ self.thread=threading.Thread(target=self.serve_forever,daemon=True); self.thread.start(); return self
51
+
52
+ def stop(self):
53
+ self.shutdown(); self.server_close()
54
+ if self.thread: self.thread.join(timeout=3)
55
+
56
+
57
+ class TeamHandler(BaseHTTPRequestHandler):
58
+ server_version=f'Lannotex/{__version__}'
59
+ def log_message(self,*args): pass # Never log access tokens or private image names.
60
+
61
+ def reply(self,code,payload):
62
+ raw=json.dumps(payload).encode()
63
+ self.send_response(code); self.send_header('Content-Type','application/json')
64
+ self.send_header('Content-Length',str(len(raw))); self.send_header('Cache-Control','no-store'); self.end_headers(); self.wfile.write(raw)
65
+
66
+ def do_GET(self): self.dispatch(False)
67
+ def do_PUT(self): self.dispatch(True)
68
+
69
+ def dispatch(self,write):
70
+ project=None
71
+ try:
72
+ self.connection.settimeout(15)
73
+ project=Project(self.server.root,create=False)
74
+ token=self.headers.get('Authorization','').removeprefix('Bearer ')
75
+ member=project.conn.execute('SELECT * FROM team_members WHERE token_hash=? AND active=1',(hashlib.sha256(token.encode()).hexdigest(),)).fetchone()
76
+ if not member: self.reply(401,{'error':'Invalid or revoked reviewer token'}); return
77
+ parsed=urlparse(self.path); route=parsed.path.strip('/').split('/'); query=parse_qs(parsed.query)
78
+ if route==['meta'] and not write:
79
+ self.reply(200,{'project_id':project.setting('project_id'),'name':project.setting('name',project.root.name),
80
+ 'tasks':project.setting('tasks',['box','polygon','classification']),'labels':[dict(r) for r in project.labels()],
81
+ 'reviewer':member['name']}); return
82
+ if route==['changes'] and not write:
83
+ project.conn.execute('BEGIN')
84
+ after=max(0,int(query.get('after',['0'])[0]))
85
+ changes=project.conn.execute('SELECT seq,image_id FROM team_changes WHERE seq>? ORDER BY seq LIMIT 128',(after,)).fetchall()
86
+ images=[]
87
+ for ident in dict.fromkeys(r['image_id'] for r in changes):
88
+ row=project.image(ident)
89
+ if row:
90
+ data=dict(row); data['path']=Path(data['path']).name
91
+ data['revision']=project.conn.execute('SELECT revision FROM revisions WHERE image_id=?',(ident,)).fetchone()[0]
92
+ images.append(data)
93
+ self.reply(200,{'images':images,'cursor':changes[-1]['seq'] if changes else after,'more':len(changes)==128}); return
94
+ if len(route)!=2 or route[0] not in ('state','image') or not route[1].isdigit(): self.reply(404,{'error':'Unknown endpoint'}); return
95
+ ident=int(route[1]); image=project.image(ident)
96
+ if image is None: self.reply(404,{'error':'Unknown image'}); return
97
+ if route[0]=='image' and not write:
98
+ path=project.resolve(image['path'])
99
+ # IDs resolve exclusively through project records, never URL paths.
100
+ with path.open('rb') as stream:
101
+ size=os.fstat(stream.fileno()).st_size
102
+ if size>256*1024*1024: self.reply(413,{'error':'Image exceeds 256 MiB transfer limit'}); return
103
+ self.send_response(200); self.send_header('Content-Type','application/octet-stream'); self.send_header('Content-Length',str(size)); self.end_headers()
104
+ while chunk:=stream.read(1024*1024): self.wfile.write(chunk)
105
+ return
106
+ if route[0]!='state': self.reply(405,{'error':'Method not allowed'}); return
107
+ if write:
108
+ size=int(self.headers.get('Content-Length','0'))
109
+ if not 0<size<=4*1024*1024: self.reply(413,{'error':'Annotation payload must be under 4 MiB'}); return
110
+ data=json.loads(self.rfile.read(size)); anns=[Annotation.from_dict(a) for a in data['annotations']]
111
+ if len(anns)>10000: raise ValueError('Too many annotations')
112
+ allowed=set(project.setting('tasks',['box','polygon','classification']))
113
+ if any(a.kind not in allowed for a in anns): raise ValueError('Annotation type is not enabled for this pipeline')
114
+ if not isinstance(data['revision'],int) or not isinstance(data['negative'],bool): raise ValueError('Invalid revision or no-target value')
115
+ project.save_annotations(ident,anns,data['negative'],actor=member['name'],expected_revision=data['revision'],clear_draft=False)
116
+ # A read transaction keeps revision, annotations and status consistent.
117
+ project.conn.execute('BEGIN')
118
+ revision=project.conn.execute('SELECT revision FROM revisions WHERE image_id=?',(ident,)).fetchone()[0]
119
+ annotations=project.load_annotations(ident); status=project.image(ident)['status']
120
+ self.reply(200,{'revision':revision,'annotations':[a.to_dict() for a in annotations],'negative':status=='empty','status':status})
121
+ except ConflictError as e: self.reply(409,{'error':str(e)})
122
+ except (ValueError,KeyError,TypeError) as e: self.reply(400,{'error':str(e)})
123
+ except FileNotFoundError: self.reply(404,{'error':'Source image is missing on the host'})
124
+ except (BrokenPipeError,ConnectionResetError): pass
125
+ except Exception: self.reply(500,{'error':'Host operation failed; no successful save was confirmed'})
126
+ finally:
127
+ if project: project.close()
128
+
129
+
130
+ class TeamClient:
131
+ def __init__(self,url,token):
132
+ self.url=url.rstrip('/'); self.token=token
133
+ parsed=urlparse(self.url)
134
+ if parsed.scheme not in ('http','https') or not parsed.hostname or parsed.username or parsed.query or parsed.fragment:
135
+ raise ValueError('Enter a plain http:// or https:// host URL')
136
+
137
+ def request(self,path,data=None):
138
+ req=Request(self.url+path,data=json.dumps(data).encode() if data is not None else None,
139
+ headers={'Authorization':'Bearer '+self.token,'Content-Type':'application/json'},method='PUT' if data is not None else 'GET')
140
+ try:
141
+ with urlopen(req,timeout=10) as response: return json.loads(response.read(8*1024*1024))
142
+ except HTTPError as e:
143
+ message=json.loads(e.read()).get('error',str(e))
144
+ if e.code==409: raise ConflictError(message) from e
145
+ raise ValueError(message) from e
146
+
147
+ def download(self,ident,path,cancel=None):
148
+ path=Path(path); path.parent.mkdir(parents=True,exist_ok=True)
149
+ req=Request(self.url+f'/image/{ident}',headers={'Authorization':'Bearer '+self.token})
150
+ fd,temp=tempfile.mkstemp(dir=path.parent,suffix='.part')
151
+ try:
152
+ with os.fdopen(fd,'wb') as stream, urlopen(req,timeout=15) as response:
153
+ total=0
154
+ while chunk:=response.read(1024*1024):
155
+ if cancel and cancel.is_set(): raise ValueError('Download cancelled')
156
+ total+=len(chunk)
157
+ if total>256*1024*1024: raise ValueError('Image transfer exceeds 256 MiB')
158
+ stream.write(chunk)
159
+ stream.flush(); os.fsync(stream.fileno())
160
+ os.replace(temp,path)
161
+ finally:
162
+ if os.path.exists(temp): os.unlink(temp)
163
+
164
+
165
+ def sync_replica(root,client,cancel,progress):
166
+ """Push local outbox, then pull incremental changes; preserve drafts/conflicts."""
167
+ local=Project(root,create=False); conflicts=[]; changed=[]; sent=0
168
+ try:
169
+ meta=client.request('/meta')
170
+ if local.setting('remote_id') not in (None,meta['project_id']): raise ValueError('This cache belongs to a different team project')
171
+ local.set_setting('remote_id',meta['project_id']); local.set_setting('team_remote',client.url)
172
+ local.set_setting('tasks',meta['tasks']); local.set_setting('name',meta['name']); local.set_setting('reviewer',meta['reviewer'])
173
+ with local.conn:
174
+ for lab in meta['labels']:
175
+ local.conn.execute('INSERT OR REPLACE INTO labels VALUES(?,?)',(lab['name'],lab['color']))
176
+ for link in local.conn.execute('SELECT * FROM team_links WHERE dirty=1 AND conflict=0').fetchall():
177
+ if cancel.is_set(): break
178
+ ident=link['image_id']; row=local.image(ident)
179
+ payload={'revision':link['remote_revision'],'annotations':[a.to_dict() for a in local.load_annotations(ident)],'negative':row['status']=='empty'}
180
+ try: result=client.request(f'/state/{ident}',payload)
181
+ except ConflictError:
182
+ with local.conn: local.conn.execute('UPDATE team_links SET conflict=1 WHERE image_id=?',(ident,))
183
+ conflicts.append(ident); continue
184
+ with local.conn:
185
+ # A user may have edited again while the request was in flight.
186
+ current=[a.to_dict() for a in local.load_annotations(ident)]
187
+ same=current==payload['annotations'] and (local.image(ident)['status']=='empty')==payload['negative']
188
+ local.conn.execute('UPDATE team_links SET remote_revision=?,dirty=? WHERE image_id=?',(result['revision'],0 if same else 1,ident))
189
+ sent+=1
190
+ cursor=local.setting('remote_cursor',0)
191
+ while not cancel.is_set():
192
+ page=client.request('/changes?after='+str(cursor))
193
+ for remote in page['images']:
194
+ ident=remote['id']; link=local.conn.execute('SELECT * FROM team_links WHERE image_id=?',(ident,)).fetchone()
195
+ draft=local.conn.execute('SELECT 1 FROM drafts WHERE image_id=?',(ident,)).fetchone()
196
+ if link and (link['dirty'] or draft):
197
+ if remote['revision']!=link['remote_revision']:
198
+ conflicts.append(ident)
199
+ with local.conn: local.conn.execute('UPDATE team_links SET conflict=1 WHERE image_id=?',(ident,))
200
+ continue
201
+ if link and remote['revision']==link['remote_revision']: continue
202
+ state=client.request(f'/state/{ident}')
203
+ suffix=Path(remote['path']).suffix.lower()
204
+ if suffix not in ('.jpg','.jpeg','.png','.webp'): raise ValueError('Unsupported remote image extension')
205
+ ref=f'images/{ident:08d}_{Path(remote["path"]).name}'
206
+ if Path(ref).name!=ref.split('/')[-1] or '\\' in ref: raise ValueError('Unsafe remote filename')
207
+ annotations=[Annotation.from_dict(a) for a in state['annotations']]
208
+ errors=validate_all(annotations,{r['name'] for r in local.labels()},state['negative'])
209
+ if errors: raise ValueError('; '.join(errors))
210
+ with local.conn:
211
+ local.conn.execute('BEGIN IMMEDIATE')
212
+ latest=local.conn.execute('SELECT * FROM team_links WHERE image_id=?',(ident,)).fetchone()
213
+ has_draft=local.conn.execute('SELECT 1 FROM drafts WHERE image_id=?',(ident,)).fetchone()
214
+ if latest and (latest['dirty'] or has_draft):
215
+ if state['revision']!=latest['remote_revision']:
216
+ local.conn.execute('UPDATE team_links SET conflict=1 WHERE image_id=?',(ident,)); conflicts.append(ident)
217
+ continue
218
+ local.conn.execute('INSERT OR IGNORE INTO images(id,path,width,height,status) VALUES(?,?,?,?,?)',(ident,ref,remote['width'],remote['height'],state['status']))
219
+ local.conn.execute('DELETE FROM annotations WHERE image_id=?',(ident,))
220
+ local.conn.executemany('INSERT INTO annotations VALUES(?,?,?)',[(a.id,ident,json.dumps(a.to_dict())) for a in annotations])
221
+ local.conn.execute('UPDATE images SET status=? WHERE id=?',(state['status'],ident))
222
+ local.conn.execute('UPDATE revisions SET revision=revision+1 WHERE image_id=?',(ident,))
223
+ local.conn.execute('INSERT OR REPLACE INTO team_links VALUES(?,?,0,0)',(ident,state['revision']))
224
+ changed.append(ident)
225
+ cursor=page['cursor']; local.set_setting('remote_cursor',cursor)
226
+ progress(len(changed),f'Synced {len(changed)} images · {sent} saves sent')
227
+ if not page['more']: break
228
+ conflicts.extend(row[0] for row in local.conn.execute('SELECT image_id FROM team_links WHERE conflict=1'))
229
+ return {'changed':changed,'conflicts':list(set(conflicts)),'sent':sent,'root':str(root),'meta':meta}
230
+ finally: local.close()
231
+
232
+
233
+ def resolve_conflict(root,ident,client,keep_local):
234
+ local=Project(root,create=False)
235
+ try:
236
+ state=client.request(f'/state/{ident}')
237
+ if keep_local:
238
+ anns,negative,_=local.load_state(ident)
239
+ state=client.request(f'/state/{ident}',{'revision':state['revision'],'annotations':[a.to_dict() for a in anns],'negative':negative})
240
+ with local.conn:
241
+ local.conn.execute('DELETE FROM annotations WHERE image_id=?',(ident,))
242
+ local.conn.executemany('INSERT INTO annotations VALUES(?,?,?)',[(a['id'],ident,json.dumps(a)) for a in state['annotations']])
243
+ local.conn.execute('DELETE FROM drafts WHERE image_id=?',(ident,))
244
+ local.conn.execute('UPDATE images SET status=? WHERE id=?',(state['status'],ident))
245
+ local.conn.execute('UPDATE revisions SET revision=revision+1 WHERE image_id=?',(ident,))
246
+ local.conn.execute('UPDATE team_links SET remote_revision=?,dirty=0,conflict=0 WHERE image_id=?',(state['revision'],ident))
247
+ return ident
248
+ finally: local.close()
lannotex/theme.py ADDED
@@ -0,0 +1,58 @@
1
+ from PySide6.QtGui import QColor, QPalette
2
+
3
+
4
+ def apply_theme(app, dark=True):
5
+ bg, panel, raised = ('#141414','#1b1b1b','#292929') if dark else ('#fafafa','#ffffff','#f0f0f0')
6
+ text, muted, border = ('#eeeeee','#999999','#333333') if dark else ('#181818','#707070','#e3e3e3')
7
+ accent, on_accent = ('#eeeeee','#171717') if dark else ('#202020','#ffffff')
8
+ palette=QPalette()
9
+ for role,color in [(QPalette.Window,bg),(QPalette.WindowText,text),(QPalette.Base,panel),
10
+ (QPalette.AlternateBase,raised),(QPalette.Text,text),(QPalette.Button,panel),
11
+ (QPalette.ButtonText,text),(QPalette.Highlight,accent),(QPalette.HighlightedText,on_accent),
12
+ (QPalette.ToolTipBase,panel),(QPalette.ToolTipText,text)]: palette.setColor(role,QColor(color))
13
+ app.setPalette(palette)
14
+ app.setStyleSheet(f'''
15
+ QWidget {{ font-size: 10pt; color: {text}; }}
16
+ QMainWindow, QDialog, QWidget#home, QWidget#workspace {{ background: {bg}; }}
17
+ QWidget#rail {{ background:{panel}; border-right:1px solid {border}; }}
18
+ QFrame#card {{ background:{panel}; border:1px solid {border}; border-radius:12px; }}
19
+ QLabel#hero {{ font-size:30pt; font-weight:600; letter-spacing:-1px; }}
20
+ QLabel#subtitle {{ font-size:12pt; color:{muted}; }}
21
+ QLabel#cardTitle {{ font-size:14pt; font-weight:600; }}
22
+ QPushButton#nav {{ text-align:left; padding:12px 16px; border:0; background:transparent; }}
23
+ QPushButton#nav:hover {{ background:{raised}; }}
24
+ QPushButton#pipeline {{ text-align:left; padding:0; border-radius:10px; min-height:112px; }}
25
+ QPushButton#pipeline:checked {{ border:2px solid {accent}; background:{raised}; color:{text}; }}
26
+ QFrame#header {{ background:{panel}; border-bottom:1px solid {border}; }}
27
+ QFrame#panel, QScrollArea {{ background:{panel}; border:1px solid {border}; border-radius:8px; }}
28
+ QLabel#brand {{ font-size:19pt; font-weight:700; }}
29
+ QLabel#section {{ color:{muted}; font-size:9pt; font-weight:600; padding-top:8px; }}
30
+ QLabel#muted {{ color:{muted}; }}
31
+ QLabel#save {{ color:{accent}; padding:6px 12px; background:{raised}; border-radius:6px; }}
32
+ QPushButton, QToolButton {{ background:{panel}; border:1px solid {border}; border-radius:5px; padding:7px 11px; }}
33
+ QPushButton:hover, QToolButton:hover {{ background:{raised}; border-color:{muted}; }}
34
+ QPushButton:pressed, QToolButton:checked {{ background:{raised}; border-color:{accent}; color:{accent}; }}
35
+ QPushButton#primary {{ background:{accent}; border-color:{accent}; color:{on_accent}; font-weight:600; }}
36
+ QPushButton#danger {{ color:{text}; }}
37
+ QPushButton:disabled, QToolButton:disabled {{ color:{muted}; border-color:{border}; }}
38
+ QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox, QKeySequenceEdit {{ background:{bg}; border:1px solid {border}; border-radius:5px; padding:7px; selection-background-color:{accent}; }}
39
+ QLineEdit:focus, QComboBox:focus, QDoubleSpinBox:focus {{ border-color:{accent}; }}
40
+ QComboBox::drop-down {{ border:0; width:20px; }}
41
+ QListView, QListWidget, QTableWidget, QTextEdit {{ background:{panel}; border:0; outline:0; }}
42
+ QListView::item, QListWidget::item {{ border-bottom:1px solid {border}; padding:6px; }}
43
+ QListView::item:selected, QListWidget::item:selected {{ background:{raised}; color:{text}; border-left:3px solid {accent}; }}
44
+ QListView::item:hover {{ background:{raised}; }}
45
+ QSplitter::handle {{ background:{bg}; width:8px; }}
46
+ QMenuBar, QMenu {{ background:{panel}; }}
47
+ QMenuBar::item:selected, QMenu::item:selected {{ background:{raised}; }}
48
+ QMenu::item {{ padding:7px 26px; }}
49
+ QStatusBar {{ background:{panel}; border-top:1px solid {border}; }}
50
+ QStatusBar::item {{ border:0; }}
51
+ QScrollBar:vertical {{ background:{panel}; width:9px; margin:0; }}
52
+ QScrollBar::handle:vertical {{ background:{border}; min-height:30px; border-radius:4px; }}
53
+ QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height:0; }}
54
+ QProgressBar {{ border:0; background:{raised}; border-radius:4px; text-align:center; }}
55
+ QProgressBar::chunk {{ background:{accent}; border-radius:4px; }}
56
+ QCheckBox {{ spacing:7px; padding:4px 0; }}
57
+ QToolTip {{ background:{panel}; color:{text}; border:1px solid {border}; padding:6px; }}
58
+ ''')
lannotex/workers.py ADDED
@@ -0,0 +1,124 @@
1
+ """Cancellable I/O jobs and serial, bounded background image decoding."""
2
+ from collections import OrderedDict
3
+ import os
4
+ from pathlib import Path
5
+ import threading
6
+ import time
7
+ from PIL import Image, ImageOps
8
+ from PySide6.QtCore import QObject, QRunnable, QThreadPool, Signal, Slot
9
+ from PySide6.QtGui import QImage
10
+ from .storage import Project
11
+
12
+ FORMATS = {'.jpg', '.jpeg', '.png', '.webp'}
13
+
14
+
15
+ class Events(QObject):
16
+ result = Signal(object)
17
+ error = Signal(str)
18
+ progress = Signal(int, str)
19
+ finished = Signal()
20
+
21
+
22
+ class Job(QRunnable):
23
+ def __init__(self, function):
24
+ super().__init__()
25
+ self.function = function
26
+ self.events = Events()
27
+ self.cancel = threading.Event()
28
+
29
+ def run(self):
30
+ try: self.events.result.emit(self.function(self.cancel, self.events.progress.emit))
31
+ except Exception as error: self.events.error.emit(str(error))
32
+ finally: self.events.finished.emit()
33
+
34
+
35
+ def import_images(root, sources, cancel, progress):
36
+ project = Project(root, create=False)
37
+ added, found, damaged, batch = 0, 0, [], []
38
+ started = time.perf_counter()
39
+ def paths():
40
+ for source in sources:
41
+ source = Path(source)
42
+ if source.is_dir():
43
+ for folder, _, files in os.walk(source):
44
+ if cancel.is_set(): return
45
+ for name in files:
46
+ if Path(name).suffix.lower() in FORMATS: yield Path(folder)/name
47
+ else: yield source
48
+ try:
49
+ for path in paths():
50
+ if cancel.is_set(): break
51
+ found += 1
52
+ try:
53
+ if path.suffix.lower() not in FORMATS: raise ValueError('Unsupported image format')
54
+ with Image.open(path) as im:
55
+ width, height = im.size
56
+ if im.getexif().get(274,1) in (5,6,7,8): width,height = height,width
57
+ # PNG EXIF lookup may consume the file; verify needs a fresh reader.
58
+ with Image.open(path) as im:
59
+ im.verify()
60
+ batch.append((project.reference(path),width,height))
61
+ except Exception as error:
62
+ if len(damaged)<100: damaged.append(f'{path.name}: {error}')
63
+ if len(batch)>=128:
64
+ added += project.add_batch(batch); batch.clear()
65
+ if found%32==0: progress(found, f'Inspected {found:,} images · {added+len(batch):,} imported')
66
+ if batch: added += project.add_batch(batch)
67
+ return {'added':added,'scanned':found,'errors':damaged,'cancelled':cancel.is_set(),'seconds':time.perf_counter()-started}
68
+ finally: project.close()
69
+
70
+
71
+ def decode(path):
72
+ # Display preview is capped; stored coordinates/dimensions remain full resolution.
73
+ with Image.open(path) as image:
74
+ image.draft('RGB',(2560,2560))
75
+ image = ImageOps.exif_transpose(image)
76
+ image.thumbnail((2560,2560),Image.Resampling.LANCZOS)
77
+ image = image.convert('RGBA')
78
+ data = image.tobytes()
79
+ return QImage(data,image.width,image.height,image.width*4,QImage.Format_RGBA8888).copy()
80
+
81
+
82
+ class ImageLoader(QObject):
83
+ ready = Signal(object, object)
84
+ failed = Signal(object, str)
85
+
86
+ def __init__(self, parent=None, budget=96*1024*1024):
87
+ super().__init__(parent)
88
+ self.pool = QThreadPool(self); self.pool.setMaxThreadCount(1)
89
+ self.cache = OrderedDict(); self.bytes = 0; self.budget = budget
90
+ self.active = None; self.pending = None
91
+
92
+ def request(self, token, path):
93
+ path = str(path)
94
+ try: key = (path, Path(path).stat().st_mtime_ns)
95
+ except OSError as e: self.failed.emit(token,str(e)); return
96
+ if key in self.cache:
97
+ self.cache.move_to_end(key); self.ready.emit(token,self.cache[key]); return
98
+ if self.active:
99
+ self.pending = (token,path); return
100
+ job = Job(lambda cancel,progress: (token,key,decode(path)))
101
+ self.active = job
102
+ job.events.result.connect(self._loaded)
103
+ job.events.error.connect(self._error)
104
+ job.events.finished.connect(self._finished)
105
+ self.active_token = token
106
+ self.pool.start(job)
107
+
108
+ @Slot(object)
109
+ def _loaded(self, payload):
110
+ token,key,image = payload
111
+ size = image.sizeInBytes()
112
+ while self.cache and self.bytes+size > self.budget:
113
+ _, previous = self.cache.popitem(last=False); self.bytes -= previous.sizeInBytes()
114
+ if size <= self.budget: self.cache[key] = image; self.bytes += size
115
+ self.ready.emit(token,image)
116
+
117
+ @Slot(str)
118
+ def _error(self, error): self.failed.emit(self.active_token,error)
119
+
120
+ @Slot()
121
+ def _finished(self):
122
+ self.active = None
123
+ if self.pending:
124
+ token,path = self.pending; self.pending = None; self.request(token,path)
@@ -0,0 +1,204 @@
1
+ Metadata-Version: 2.4
2
+ Name: lannotex
3
+ Version: 0.1.0
4
+ Summary: Fast, offline-first image annotation for classifications, boxes, and polygons.
5
+ Author: Lourdhu Raju
6
+ Maintainer: Lourdhu Raju
7
+ License-Expression: MIT
8
+ Project-URL: Homepage, https://github.com/Lourdhu02/Lannotex
9
+ Project-URL: Source, https://github.com/Lourdhu02/Lannotex
10
+ Project-URL: Issues, https://github.com/Lourdhu02/Lannotex/issues
11
+ Project-URL: Documentation, https://lourdhu02.github.io/Lannotex/
12
+ Project-URL: Changelog, https://github.com/Lourdhu02/Lannotex/blob/main/CHANGELOG.md
13
+ Keywords: annotation,labeling,computer-vision,yolo,coco,labelme,qt,pyside6,machine-learning
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Environment :: MacOS X
16
+ Classifier: Environment :: Win32 (MS Windows)
17
+ Classifier: Environment :: X11 Applications :: Qt
18
+ Classifier: Intended Audience :: Developers
19
+ Classifier: Intended Audience :: Science/Research
20
+ Classifier: Operating System :: OS Independent
21
+ Classifier: Programming Language :: Python :: 3
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
26
+ Classifier: Topic :: Multimedia :: Graphics
27
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
28
+ Classifier: Topic :: Scientific/Engineering :: Image Processing
29
+ Requires-Python: >=3.11
30
+ Description-Content-Type: text/markdown
31
+ License-File: LICENSE
32
+ License-File: LICENSES/THIRD_PARTY_NOTICES.md
33
+ Requires-Dist: PySide6-Essentials<7,>=6.8.3
34
+ Requires-Dist: Pillow>=11.1
35
+ Provides-Extra: dev
36
+ Requires-Dist: pytest>=8.3; extra == "dev"
37
+ Requires-Dist: pyinstaller>=6.11.1; extra == "dev"
38
+ Requires-Dist: ruff>=0.11.2; extra == "dev"
39
+ Requires-Dist: build>=1.2.2; extra == "dev"
40
+ Requires-Dist: twine>=6.1; extra == "dev"
41
+ Dynamic: license-file
42
+
43
+ <div align="center">
44
+
45
+ <img src="https://raw.githubusercontent.com/Lourdhu02/Lannotex/main/docs/assets/logo.svg" width="88" alt="Lannotex logo">
46
+
47
+ # Lannotex
48
+
49
+ **Fast, offline-first image annotation for bounding boxes, polygons, and image classes.**<br>
50
+ Free and MIT-licensed. LabelMe-compatible. Your images never leave your machine.
51
+
52
+ [![CI](https://img.shields.io/github/actions/workflow/status/Lourdhu02/Lannotex/ci.yml?branch=main&label=tests)](https://github.com/Lourdhu02/Lannotex/actions/workflows/ci.yml)
53
+ [![Docs](https://img.shields.io/github/actions/workflow/status/Lourdhu02/Lannotex/docs.yml?branch=main&label=docs)](https://lourdhu02.github.io/Lannotex/)
54
+ [![Python](https://img.shields.io/badge/python-3.11%20%E2%80%93%203.14-3776ab)](https://lourdhu02.github.io/Lannotex/getting-started/installation/)
55
+ [![Platforms](https://img.shields.io/badge/Windows%20%C2%B7%20macOS%20%C2%B7%20Linux-supported-555)](https://github.com/Lourdhu02/Lannotex/releases)
56
+ [![License: MIT](https://img.shields.io/github/license/Lourdhu02/Lannotex?color=37b8a5)](https://github.com/Lourdhu02/Lannotex/blob/main/LICENSE)
57
+
58
+ [**Documentation**](https://lourdhu02.github.io/Lannotex/) ·
59
+ [Quick start](https://lourdhu02.github.io/Lannotex/getting-started/quickstart/) ·
60
+ [Coming from LabelMe](https://lourdhu02.github.io/Lannotex/migrate-from-labelme/) ·
61
+ [Releases](https://github.com/Lourdhu02/Lannotex/releases)
62
+
63
+ <img src="https://raw.githubusercontent.com/Lourdhu02/Lannotex/main/docs/assets/demo.gif" width="860" alt="Drawing two boxes and a polygon on a cat photo in Lannotex, then saving and moving to the next image">
64
+
65
+ </div>
66
+
67
+ ## Why Lannotex?
68
+
69
+ - **Everything you need is free.** YOLO, COCO, Labelme and CSV export, standalone desktop builds, and team review are all free and open source. No license keys, no Pro tier.
70
+ - **Built for real datasets.** A project is one SQLite file instead of thousands of JSON files. The image list is virtualized, so its memory use stays flat; it was [benchmarked](https://lourdhu02.github.io/Lannotex/PERFORMANCE/) at 100,000 records. Your source images are never moved or modified.
71
+ - **You can't lose work.** Every edit is written to a recovery draft, saves are transactional, and broken geometry (self-intersecting polygons, zero-size boxes) is rejected before it reaches your dataset.
72
+ - **Private by default.** No account, no telemetry, no cloud. The app makes network requests only if you choose to host or join a team on your own network.
73
+
74
+ ## Install
75
+
76
+ ```bash
77
+ uv tool install lannotex # recommended, isolated install
78
+ lannotex
79
+ ```
80
+
81
+ <details>
82
+ <summary>pip, pipx, standalone builds, and source</summary>
83
+
84
+ ```bash
85
+ pipx install lannotex
86
+ # or, inside a virtual environment
87
+ python -m pip install lannotex
88
+ ```
89
+
90
+ **Standalone builds** for Windows, macOS, and Linux are attached to every
91
+ [release](https://github.com/Lourdhu02/Lannotex/releases). They bundle Python and Qt, so
92
+ nothing else is needed.
93
+
94
+ **From source:**
95
+
96
+ ```bash
97
+ git clone https://github.com/Lourdhu02/Lannotex.git && cd Lannotex
98
+ uv venv && uv pip install -e ".[dev]"
99
+ lannotex
100
+ ```
101
+
102
+ </details>
103
+
104
+ Lannotex supports Python 3.11–3.14 on Windows, macOS, and Linux. See the
105
+ [installation guide](https://lourdhu02.github.io/Lannotex/getting-started/installation/)
106
+ for details.
107
+
108
+ ## Your first dataset in 60 seconds
109
+
110
+ 1. **Create a project** and pick a pipeline: *Classification*, *Object detection*, *Segmentation*, or *Combined*. Each one shows only the tools it needs.
111
+ 2. **Import a folder** (`Ctrl+I`). JPEG, PNG and WebP files are checked and referenced in place, not copied.
112
+ 3. **Add labels**, then draw: `B` for boxes, `P` for polygons (`Enter` finishes), `V` to select and edit.
113
+ 4. **Save & next** with `Ctrl+Enter`. Autosave and recovery drafts run in the background anyway.
114
+ 5. **Export** to the format your training code expects.
115
+
116
+ Every shortcut can be remapped. See the [annotating guide](https://lourdhu02.github.io/Lannotex/guide/annotating/) for the full list.
117
+
118
+ <table>
119
+ <tr>
120
+ <td><img src="https://raw.githubusercontent.com/Lourdhu02/Lannotex/main/docs/assets/workspace-light.png" alt="Lannotex workspace in light theme with a polygon and a box on a coffee cup"></td>
121
+ <td><img src="https://raw.githubusercontent.com/Lourdhu02/Lannotex/main/docs/assets/workspace-dark.png" alt="Lannotex workspace in dark theme with boxes on an astronaut photo"></td>
122
+ </tr>
123
+ <tr>
124
+ <td align="center">Light theme · polygon vertex editing</td>
125
+ <td align="center">Dark theme · object detection</td>
126
+ </tr>
127
+ </table>
128
+
129
+ ## Features
130
+
131
+ | | |
132
+ | --- | --- |
133
+ | **Annotate** | Bounding boxes · polygons with vertex drag, insert, and delete · multi-label image classes · "no target" negatives · 100-step undo/redo · zoom under the pointer, pan, arrow-key nudging · focus mode · light and dark themes |
134
+ | **Stay safe** | SQLite projects with versioned migrations · recovery drafts and 700 ms autosave · transactional saves · geometry validation · backup and restore · relinking for moved images · EXIF orientation handled |
135
+ | **Scale** | Virtualized image list (at most 1,024 records in memory) · background import and decoding · 96 MB preview cache · coordinates normalized to the original resolution |
136
+ | **Export** | Labelme JSON · YOLO · COCO JSON · classification CSV · lossless native JSONL · each export reports anything it skipped |
137
+ | **Automate** | `lannotex export` and `lannotex analyze` run headless in CI, with no display needed |
138
+ | **Collaborate** | Optional LAN host with per-reviewer tokens, background sync, revision conflicts you resolve explicitly, revocation, and an audit history |
139
+
140
+ ## Lannotex vs. LabelMe
141
+
142
+ LabelMe is a great tool and Lannotex reads and writes its JSON format, so you can switch
143
+ without converting anything.
144
+
145
+ | | Lannotex | LabelMe |
146
+ | --- | :---: | :---: |
147
+ | License | MIT | GPL-3.0 |
148
+ | Standalone desktop app | Free | Paid license |
149
+ | YOLO export | ✅ Free | Pro tier |
150
+ | COCO export | ✅ | ✅ |
151
+ | One database per dataset | ✅ | One JSON per image |
152
+ | Crash recovery and transactional saves | ✅ | Optional autosave |
153
+ | Team review with conflict handling | ✅ | — |
154
+ | Headless export and validation CLI | ✅ | Example scripts |
155
+ | AI-assisted annotation (SAM) | Not yet | ✅ |
156
+ | Circles, lines, points, rotated boxes | Not yet | ✅ |
157
+
158
+ Based on LabelMe's published features and pricing as of October 2026. The
159
+ [full comparison and migration guide](https://lourdhu02.github.io/Lannotex/migrate-from-labelme/)
160
+ covers which LabelMe shapes import today.
161
+
162
+ ## Command line
163
+
164
+ ```bash
165
+ lannotex new ./dataset # create a project
166
+ lannotex analyze ./dataset # JSON health report: counts, problems, drafts
167
+ lannotex export ./dataset ./out --format "COCO JSON" # Labelme JSON | YOLO boxes | COCO JSON | Classification CSV | Native JSONL
168
+ lannotex invite ./dataset "Alice" # print a one-time reviewer token
169
+ lannotex host ./dataset --bind 0.0.0.0 --port 8765 # share a project on your LAN (TLS via --cert/--key)
170
+ ```
171
+
172
+ `analyze` writes clean JSON to stdout, so it can gate a training pipeline. See the
173
+ [CLI reference](https://lourdhu02.github.io/Lannotex/cli/).
174
+
175
+ ## Privacy
176
+
177
+ Solo projects make no network requests. Team mode connects only to the host address you
178
+ enter, reviewer tokens are kept in memory on clients, and the host stores only token
179
+ hashes. There is no telemetry, no external AI service, and no automatic upload. Read the
180
+ [team guide](https://lourdhu02.github.io/Lannotex/TEAM/) for LAN and TLS setup.
181
+
182
+ ## Roadmap
183
+
184
+ Next up: a ready-to-train YOLO layout (`images/`, `labels/`, `data.yaml`), more LabelMe
185
+ shape types, VOC and YOLO-seg export, and number-key label selection. See the
186
+ [roadmap](https://lourdhu02.github.io/Lannotex/ROADMAP/) and the
187
+ [measured performance](https://lourdhu02.github.io/Lannotex/PERFORMANCE/).
188
+
189
+ ## Contributing
190
+
191
+ Bug reports, ideas, and pull requests are welcome. Start with
192
+ [CONTRIBUTING.md](https://github.com/Lourdhu02/Lannotex/blob/main/CONTRIBUTING.md), and
193
+ please follow the [Code of Conduct](https://github.com/Lourdhu02/Lannotex/blob/main/CODE_OF_CONDUCT.md).
194
+ Report security issues privately, as described in
195
+ [SECURITY.md](https://github.com/Lourdhu02/Lannotex/blob/main/SECURITY.md).
196
+
197
+ If Lannotex saves you time, a ⭐ helps other people find it.
198
+
199
+ ## License
200
+
201
+ [MIT](https://github.com/Lourdhu02/Lannotex/blob/main/LICENSE) © 2026 [Lourdhu Raju](https://github.com/Lourdhu02). Third-party licenses are
202
+ listed in [THIRD_PARTY_NOTICES.md](https://github.com/Lourdhu02/Lannotex/blob/main/LICENSES/THIRD_PARTY_NOTICES.md).
203
+ Demo images are public-domain and CC0 samples from
204
+ [scikit-image](https://scikit-image.org/docs/stable/api/skimage.data.html).
@@ -0,0 +1,21 @@
1
+ lannotex/__init__.py,sha256=rE4sApXHMrof1G9e-zPalhIsw8WNGm3nwFt9efOE85A,70
2
+ lannotex/__main__.py,sha256=MSmt_5Xg84uHqzTN38JwgseJK8rsJn_11A8WD99VtEo,61
3
+ lannotex/app.py,sha256=9tEYPryvJq_tQz59YZDVqCcRr9yWfZHSaUUOCP5fITo,64820
4
+ lannotex/canvas.py,sha256=_c71kXqL5nCUMUPW8lCUJVJY7VVxvZ0zbbcFc3qDeTQ,12932
5
+ lannotex/cli.py,sha256=jq6sY40FgyX_B-rW-go2y9RD6qSZMRZfrfC-mt9b_Ps,3890
6
+ lannotex/domain.py,sha256=IMJs_bGkJmLinCwW8ZHXHlP9Z_xMc62nx4tlkj4i-7E,4601
7
+ lannotex/exporters.py,sha256=IKsnsjujAge9m47YS9uIO05vmWajSOkwarjzKgxHprs,12743
8
+ lannotex/home.py,sha256=wRD_UzcH7tAySo40ae630X72MNsciPyMy20GsrAjUf4,8731
9
+ lannotex/models.py,sha256=izilkI9IAsZiaTiXZ7lOMKkU4ypeJdaTJ3VB-fR5FvE,2171
10
+ lannotex/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ lannotex/storage.py,sha256=BuwT2deinlJUE291wCLCgIpopQqCQ8IuMTm9odwhSiM,14109
12
+ lannotex/team.py,sha256=30v6wPM8y_ND-2cVqutMSVuGMgm6YJyLI1efHOGdCZQ,15556
13
+ lannotex/theme.py,sha256=dUzo4RtOacA1CpPCvzjFISgmECHRmtMuqtIywuAD79o,4493
14
+ lannotex/workers.py,sha256=aqXv0YybbC123akYRZqNSdPRDtX5Zu2oaEDwcmVFeUU,4816
15
+ lannotex-0.1.0.dist-info/licenses/LICENSE,sha256=cmN7bFnVolJH65BS9k4xPmNpR_H_3XOkSKXsayDA1E4,1069
16
+ lannotex-0.1.0.dist-info/licenses/LICENSES/THIRD_PARTY_NOTICES.md,sha256=Os0J5-rk7HRUOn_clXA-0X3_6plisaki-qsa1QxfXy8,844
17
+ lannotex-0.1.0.dist-info/METADATA,sha256=GdtKc2cypBjBEMcofhGtWVc1gVl9W-a0Hp6aAxNql34,10574
18
+ lannotex-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
19
+ lannotex-0.1.0.dist-info/entry_points.txt,sha256=Agjj2djzQVRsVMe8v18VMtv0a0K2-5sZ5SLfu7IU6NE,80
20
+ lannotex-0.1.0.dist-info/top_level.txt,sha256=fuT9X_GRL45AUvLwZOJgCvF1OSEDZ3tsLT83py0F-O4,9
21
+ lannotex-0.1.0.dist-info/RECORD,,