lannotex 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lannotex/__init__.py +2 -0
- lannotex/__main__.py +4 -0
- lannotex/app.py +924 -0
- lannotex/canvas.py +289 -0
- lannotex/cli.py +97 -0
- lannotex/domain.py +119 -0
- lannotex/exporters.py +212 -0
- lannotex/home.py +130 -0
- lannotex/models.py +51 -0
- lannotex/py.typed +0 -0
- lannotex/storage.py +241 -0
- lannotex/team.py +248 -0
- lannotex/theme.py +58 -0
- lannotex/workers.py +124 -0
- lannotex-0.1.0.dist-info/METADATA +204 -0
- lannotex-0.1.0.dist-info/RECORD +21 -0
- lannotex-0.1.0.dist-info/WHEEL +5 -0
- lannotex-0.1.0.dist-info/entry_points.txt +3 -0
- lannotex-0.1.0.dist-info/licenses/LICENSE +21 -0
- lannotex-0.1.0.dist-info/licenses/LICENSES/THIRD_PARTY_NOTICES.md +11 -0
- lannotex-0.1.0.dist-info/top_level.txt +1 -0
lannotex/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
|
+
[](https://github.com/Lourdhu02/Lannotex/actions/workflows/ci.yml)
|
|
53
|
+
[](https://lourdhu02.github.io/Lannotex/)
|
|
54
|
+
[](https://lourdhu02.github.io/Lannotex/getting-started/installation/)
|
|
55
|
+
[](https://github.com/Lourdhu02/Lannotex/releases)
|
|
56
|
+
[](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,,
|