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/exporters.py ADDED
@@ -0,0 +1,212 @@
1
+ """Local editable exports. No company approval or release status is implied."""
2
+ from __future__ import annotations
3
+ import csv
4
+ import json
5
+ from pathlib import Path
6
+ import tempfile
7
+ import uuid
8
+ from . import __version__
9
+ from .domain import Annotation, polygon_area, validate_all
10
+ from .storage import Project, atomic_text
11
+
12
+
13
+ def labelme_data(image_path, width, height, annotations, negative=False):
14
+ shapes = []
15
+ for a in annotations:
16
+ if a.kind == 'classification': continue
17
+ if a.kind == 'box':
18
+ x,y,r,b = a.geometry
19
+ points = [[x*width,y*height],[r*width,b*height]]
20
+ else: points = [[x*width,y*height] for x,y in a.geometry]
21
+ shapes.append({'label':a.label,'points':points,'shape_type':'rectangle' if a.kind=='box' else 'polygon',
22
+ 'flags':{},'group_id':None,'lannotex_id':a.id})
23
+ return {'version':'5.5.0','imageData':None,'imagePath':image_path,'imageWidth':width,'imageHeight':height,
24
+ 'flags':{a.label:True for a in annotations if a.kind=='classification'}, 'shapes':shapes,
25
+ 'lannotex':{'version':__version__,'no_targets':negative,'editable':True}}
26
+
27
+
28
+ def labelme(path, image_path, width, height, annotations):
29
+ atomic_text(path,json.dumps(labelme_data(image_path,width,height,annotations),indent=2))
30
+
31
+
32
+ def parse_labelme(data):
33
+ w,h = data['imageWidth'],data['imageHeight']
34
+ if not isinstance(w,(int,float)) or not isinstance(h,(int,float)) or w<=0 or h<=0:
35
+ raise ValueError('Labelme image dimensions must be positive')
36
+ annotations = []
37
+ for shape in data.get('shapes',[]):
38
+ pts = [[x/w,y/h] for x,y in shape['points']]
39
+ kind = shape.get('shape_type','polygon')
40
+ if kind == 'rectangle' and len(pts)==2:
41
+ geometry = [min(pts[0][0],pts[1][0]),min(pts[0][1],pts[1][1]),max(pts[0][0],pts[1][0]),max(pts[0][1],pts[1][1])]
42
+ kind = 'box'
43
+ elif kind == 'polygon': geometry = pts
44
+ else: raise ValueError(f'Unsupported Labelme shape: {kind}')
45
+ annotations.append(Annotation(shape['label'],kind,geometry,shape.get('lannotex_id') or shape.get('labelreview_id') or str(uuid.uuid4())))
46
+ for label,enabled in data.get('flags',{}).items():
47
+ if enabled is True: annotations.append(Annotation(label,'classification'))
48
+ negative = bool(data.get('lannotex',{}).get('no_targets',False) or data.get('labelreview',{}).get('no_targets',False))
49
+ errors = validate_all(annotations,{a.label for a in annotations},negative)
50
+ if errors: raise ValueError('; '.join(errors))
51
+ return annotations, negative
52
+
53
+
54
+ def yolo_line(a, class_id):
55
+ if a.kind != 'box': return None
56
+ x,y,r,b = a.geometry
57
+ return f'{class_id} {(x+r)/2:.6f} {(y+b)/2:.6f} {r-x:.6f} {b-y:.6f}'
58
+
59
+
60
+ def coco_record(image, annotations, labels):
61
+ output = []
62
+ w,h = image['width'],image['height']
63
+ for a in annotations:
64
+ if a.kind not in ('box','polygon'): continue
65
+ if a.kind == 'box':
66
+ x,y,r,b = a.geometry
67
+ bbox = [x*w,y*h,(r-x)*w,(b-y)*h]
68
+ segmentation = []; area = bbox[2]*bbox[3]
69
+ else:
70
+ xs,ys = [p[0]*w for p in a.geometry],[p[1]*h for p in a.geometry]
71
+ bbox = [min(xs),min(ys),max(xs)-min(xs),max(ys)-min(ys)]
72
+ segmentation = [[n for p in a.geometry for n in (p[0]*w,p[1]*h)]]
73
+ area = polygon_area(a.geometry)*w*h
74
+ output.append({'id':len(output)+1,'image_id':image['id'],'category_id':labels.index(a.label)+1,
75
+ 'bbox':bbox,'area':area,'segmentation':segmentation,'iscrowd':0})
76
+ return {'images':[image],'categories':[{'id':i+1,'name':s} for i,s in enumerate(labels)],'annotations':output}
77
+
78
+
79
+ def export_project(root, destination, format_name, cancel, progress):
80
+ """Stream a consistent DB snapshot; publish the completed directory by rename."""
81
+ project = Project(root,create=False)
82
+ project.conn.execute('BEGIN')
83
+ destination = Path(destination)
84
+ destination.mkdir(parents=True,exist_ok=True)
85
+ output = Path(tempfile.mkdtemp(prefix='Lannotex-partial-',dir=destination))
86
+ summary = {'included':0,'unlabeled':0,'skipped':0,'unsupported_annotations':0,'errors':[], 'format':format_name}
87
+ try:
88
+ labels = [r['name'] for r in project.labels()]
89
+ atomic_text(output/'classes.json',json.dumps(labels,indent=2))
90
+ # Spool COCO arrays to disk, rather than keeping all annotations in RAM.
91
+ with (output/'images.jsonl').open('w',encoding='utf-8') as ims, (output/'annotations.jsonl').open('w',encoding='utf-8') as anns, (output/'classification.csv').open('w',encoding='utf-8',newline='') as csvfile:
92
+ writer = csv.writer(csvfile); writer.writerow(['image_id','image_path','status',*labels])
93
+ total = project.query(count=True); aid = 0
94
+ for position,row in enumerate(project.conn.execute('SELECT * FROM images ORDER BY id'),1):
95
+ if cancel.is_set():
96
+ summary['cancelled'] = True; break
97
+ annotations = project.load_annotations(row['id'])
98
+ negative = row['status']=='empty'
99
+ errors = validate_all(annotations,set(labels),negative)
100
+ if row['width']<=0 or row['height']<=0: errors.append('Invalid image dimensions')
101
+ if not project.resolve(row['path']).is_file(): errors.append('Missing source image')
102
+ if project.conn.execute('SELECT 1 FROM drafts WHERE image_id=?',(row['id'],)).fetchone(): errors.append('Uncommitted recovery draft')
103
+ if errors:
104
+ summary['skipped'] += 1
105
+ if len(summary['errors'])<100: summary['errors'].append(f"{row['path']}: {'; '.join(errors)}")
106
+ continue
107
+ summary['included'] += 1
108
+ if row['status']=='new': summary['unlabeled'] += 1
109
+ name = f"{row['id']:08d}_{Path(row['path']).stem}"
110
+ source = str(project.resolve(row['path']))
111
+ image = {'id':row['id'],'file_name':source,'width':row['width'],'height':row['height']}
112
+ ims.write(json.dumps(image)+'\n')
113
+ if format_name == 'Labelme JSON':
114
+ atomic_text(output/(name+'.json'),json.dumps(labelme_data(source,row['width'],row['height'],annotations,negative),indent=2))
115
+ elif format_name == 'YOLO boxes':
116
+ lines = [yolo_line(a,labels.index(a.label)) for a in annotations if a.kind=='box']
117
+ atomic_text(output/(name+'.txt'),'\n'.join(lines)+ ('\n' if lines else ''))
118
+ summary['unsupported_annotations'] += sum(a.kind!='box' for a in annotations)
119
+ elif format_name == 'COCO JSON':
120
+ record = coco_record(image,annotations,labels)
121
+ for ann in record['annotations']:
122
+ aid += 1; ann['id']=aid; anns.write(json.dumps(ann)+'\n')
123
+ summary['unsupported_annotations'] += sum(a.kind=='classification' for a in annotations)
124
+ elif format_name == 'Native JSONL':
125
+ anns.write(json.dumps({'image':image,'status':row['status'],'annotations':[a.to_dict() for a in annotations]})+'\n')
126
+ classes = {a.label for a in annotations if a.kind=='classification'}
127
+ writer.writerow([row['id'],source,row['status'],*[int(label in classes) for label in labels]])
128
+ if format_name=='Classification CSV': summary['unsupported_annotations'] += sum(a.kind!='classification' for a in annotations)
129
+ if position%32==0: progress(round(position/max(1,total)*100),f'Exporting {position:,} / {total:,}')
130
+ if format_name == 'COCO JSON' and not summary.get('cancelled'):
131
+ with (output/'coco.json').open('w',encoding='utf-8') as stream:
132
+ stream.write('{"images":[')
133
+ for filename,sep in [('images.jsonl','],"annotations":['),('annotations.jsonl','],"categories":')]:
134
+ first = True
135
+ with (output/filename).open(encoding='utf-8') as lines:
136
+ for line in lines:
137
+ if not first: stream.write(',')
138
+ stream.write(line.strip()); first=False
139
+ stream.write(sep)
140
+ stream.write(json.dumps([{'id':i+1,'name':s} for i,s in enumerate(labels)])+'}')
141
+ atomic_text(output/'manifest.json',json.dumps({'version':__version__,'format_version':1,'editable_local_export':True,'image_policy':'Referenced, not copied','summary':summary},indent=2))
142
+ if not summary.get('cancelled'):
143
+ final = destination/('Lannotex-export-'+uuid.uuid4().hex[:8]); output.rename(final); output=final
144
+ summary['path'] = str(output)
145
+ return summary
146
+ finally:
147
+ project.conn.rollback(); project.close()
148
+
149
+
150
+ def import_annotations(root, source, cancel, progress):
151
+ """Native JSONL or Labelme; existing labeled images are never overwritten."""
152
+ from PIL import Image
153
+ project = Project(root,create=False)
154
+ source = Path(source)
155
+ count,errors = 0,[]
156
+ try:
157
+ def records():
158
+ if source.suffix=='.jsonl':
159
+ with source.open(encoding='utf-8') as stream:
160
+ for line in stream:
161
+ record=json.loads(line)
162
+ yield record['image']['file_name'], record['image']['width'], record['image']['height'], [Annotation.from_dict(a) for a in record['annotations']], record['status']=='empty'
163
+ else:
164
+ files = sorted(source.glob('*.json')) if source.is_dir() else [source]
165
+ for file in files:
166
+ data = json.loads(file.read_text(encoding='utf-8'))
167
+ if 'shapes' not in data: continue
168
+ anns,neg = parse_labelme(data)
169
+ yield str((file.parent/data['imagePath']).resolve()),data['imageWidth'],data['imageHeight'],anns,neg
170
+ for path,w,h,annotations,negative in records():
171
+ if cancel.is_set(): break
172
+ try:
173
+ with Image.open(path) as image:
174
+ size=image.size
175
+ if image.getexif().get(274,1) in (5,6,7,8): size=size[::-1]
176
+ if size != (w,h): raise ValueError('Source dimensions differ from annotation metadata')
177
+ errors_found=validate_all(annotations,{a.label for a in annotations},negative)
178
+ if errors_found: raise ValueError('; '.join(errors_found))
179
+ reference=project.reference(path)
180
+ existing=project.conn.execute('SELECT * FROM images WHERE path=?',(reference,)).fetchone()
181
+ if existing and (existing['status']!='new' or project.conn.execute('SELECT 1 FROM drafts WHERE image_id=?',(existing['id'],)).fetchone()):
182
+ raise ValueError('Image already has annotations or a draft; skipped')
183
+ for label in {a.label for a in annotations}:
184
+ if label not in {r['name'] for r in project.labels()}: project.add_label(label)
185
+ ident=project.add_image(path,w,h)
186
+ project.save_annotations(ident,annotations,negative)
187
+ count+=1; progress(count,f'Imported annotations for {count} images')
188
+ except Exception as e:
189
+ if len(errors)<100: errors.append(f'{path}: {e}')
190
+ return {'added':count,'errors':errors,'cancelled':cancel.is_set()}
191
+ finally: project.close()
192
+
193
+
194
+ def analyze(root, cancel, progress):
195
+ project=Project(root,create=False)
196
+ result={'total':0,'labeled':0,'unlabeled':0,'negative':0,'boxes':0,'polygons':0,'classes':0,'distribution':{},'problems':[]}
197
+ try:
198
+ labels={r['name'] for r in project.labels()}
199
+ result['drafts']=project.conn.execute('SELECT COUNT(*) FROM drafts').fetchone()[0]
200
+ for row in project.conn.execute('SELECT * FROM images'):
201
+ if cancel.is_set(): break
202
+ result['total']+=1
203
+ result[{'new':'unlabeled','done':'labeled','empty':'negative'}[row['status']]]+=1
204
+ annotations=project.load_annotations(row['id'])
205
+ problems=validate_all(annotations,labels,row['status']=='empty')
206
+ if not project.resolve(row['path']).is_file(): problems.append('Missing source image')
207
+ for a in annotations:
208
+ result[{'box':'boxes','polygon':'polygons','classification':'classes'}.get(a.kind,'classes')]+=1
209
+ result['distribution'][a.label]=result['distribution'].get(a.label,0)+1
210
+ if problems and len(result['problems'])<200: result['problems'].append(f"{row['path']}: {'; '.join(problems)}")
211
+ return result
212
+ finally: project.close()
lannotex/home.py ADDED
@@ -0,0 +1,130 @@
1
+ """Home, recent projects and intentional pipeline setup, independent of editor logic."""
2
+ from pathlib import Path
3
+ from PySide6.QtCore import Qt, Signal
4
+ from PySide6.QtWidgets import (QButtonGroup, QComboBox, QDialog, QDialogButtonBox,
5
+ QFileDialog, QFrame, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QListWidget,
6
+ QListWidgetItem, QMessageBox, QPushButton, QScrollArea, QSizePolicy, QTextEdit, QVBoxLayout, QWidget)
7
+
8
+ PIPELINES = {
9
+ 'classification': ('Classification', 'Assign classes to whole images. No drawing tools.', ['classification']),
10
+ 'detection': ('Object detection', 'Locate objects with editable bounding boxes.', ['box']),
11
+ 'segmentation': ('Segmentation', 'Trace precise regions with polygon vertices.', ['polygon']),
12
+ 'combined': ('Combined pipeline', 'Classes, bounding boxes and polygons together.', ['classification','box','polygon']),
13
+ }
14
+
15
+
16
+ def text(value, role='muted'):
17
+ widget=QLabel(value); widget.setObjectName(role); widget.setWordWrap(True); return widget
18
+
19
+
20
+ def pipeline_card(title,description):
21
+ card=QPushButton(); card.setObjectName('pipeline')
22
+ card.setSizePolicy(QSizePolicy.Ignored,QSizePolicy.Preferred); card.setMinimumWidth(160)
23
+ layout=QVBoxLayout(card); layout.setContentsMargins(18,16,18,16); layout.setSpacing(8)
24
+ for content,role in [(title+' ↗','cardTitle'),(description,'muted')]:
25
+ item=text(content,role); item.setAttribute(Qt.WA_TransparentForMouseEvents)
26
+ layout.addWidget(item)
27
+ card.setAccessibleName(title); return card
28
+
29
+
30
+ class Home(QWidget):
31
+ create_requested=Signal(str)
32
+ open_requested=Signal()
33
+ continue_requested=Signal(str)
34
+ join_requested=Signal()
35
+ theme_requested=Signal()
36
+ preferences_requested=Signal()
37
+
38
+ def __init__(self):
39
+ super().__init__(); self.setObjectName('home')
40
+ shell=QHBoxLayout(self); shell.setContentsMargins(0,0,0,0); shell.setSpacing(0)
41
+ rail=QWidget(); rail.setObjectName('rail'); rail.setFixedWidth(220); nav=QVBoxLayout(rail)
42
+ nav.setContentsMargins(16,28,16,20); nav.setSpacing(8)
43
+ nav.addWidget(text('◈ Lannotex','brand')); nav.addSpacing(36)
44
+ for title,signal in [('+ New project',lambda:self.create_requested.emit('classification')),
45
+ ('▤ Open project',self.open_requested.emit),('↗ Join team',self.join_requested.emit)]:
46
+ b=QPushButton(title); b.setObjectName('nav'); b.clicked.connect(signal); nav.addWidget(b)
47
+ nav.addStretch(); nav.addWidget(text('YOUR WORKSPACE\nSolo or together. Your data.'))
48
+ b=QPushButton('◐ Light / dark'); b.setObjectName('nav'); b.clicked.connect(self.theme_requested); nav.addWidget(b)
49
+ b=QPushButton('Settings && shortcuts'); b.setObjectName('nav'); b.clicked.connect(self.preferences_requested); nav.addWidget(b)
50
+ shell.addWidget(rail)
51
+ scroll=QScrollArea(); scroll.setWidgetResizable(True); scroll.setFrameShape(QFrame.NoFrame)
52
+ page=QWidget(); layout=QVBoxLayout(page); layout.setContentsMargins(52,38,52,28); layout.setSpacing(18)
53
+ layout.addStretch(1); layout.addWidget(text('A little more clarity.\nA lot better data.','hero'))
54
+ layout.addWidget(text('Choose your workflow. Pick up where you left off.','subtitle')); layout.addSpacing(12)
55
+ grid=QGridLayout(); grid.setSpacing(12)
56
+ for i,(key,(title,description,_)) in enumerate(PIPELINES.items()):
57
+ card=pipeline_card(title,description)
58
+ card.setToolTip(description); card.clicked.connect(lambda checked=False,k=key:self.create_requested.emit(k))
59
+ grid.addWidget(card,i//2,i%2)
60
+ layout.addLayout(grid); layout.addSpacing(14)
61
+ row=QHBoxLayout(); row.addWidget(text('Continue working','cardTitle')); row.addStretch()
62
+ self.continue_button=QPushButton('Continue →'); self.continue_button.setObjectName('primary')
63
+ self.continue_button.clicked.connect(self.resume); row.addWidget(self.continue_button)
64
+ b=QPushButton('Open another…'); b.clicked.connect(self.open_requested); row.addWidget(b); layout.addLayout(row)
65
+ self.recents=QListWidget(); self.recents.setMinimumHeight(130); self.recents.setMaximumHeight(260)
66
+ self.recents.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff); self.recents.setTextElideMode(Qt.ElideMiddle)
67
+ self.recents.itemDoubleClicked.connect(lambda item:self.continue_requested.emit(item.data(Qt.UserRole)))
68
+ layout.addWidget(self.recents)
69
+ self.empty=text('No recent projects yet. Create your first project above.'); layout.addWidget(self.empty)
70
+ layout.addStretch(1)
71
+ layout.addWidget(text('Solo projects work offline. Team projects connect only to the host you choose.'))
72
+ scroll.setWidget(page); shell.addWidget(scroll,1)
73
+
74
+ def refresh(self, recent):
75
+ self.recents.clear()
76
+ for path in recent:
77
+ exists=(Path(path)/'project.sqlite3').is_file()
78
+ item=QListWidgetItem(f"{Path(path).name}{'' if exists else ' · unavailable'}\n{path}")
79
+ item.setData(Qt.UserRole,path); self.recents.addItem(item)
80
+ if self.recents.count(): self.recents.setCurrentRow(0)
81
+ self.empty.setVisible(not self.recents.count()); self.continue_button.setEnabled(bool(self.recents.count()))
82
+
83
+ def resume(self):
84
+ item=self.recents.currentItem()
85
+ if item: self.continue_requested.emit(item.data(Qt.UserRole))
86
+
87
+
88
+ class CreateProjectDialog(QDialog):
89
+ def __init__(self, pipeline='classification', parent=None):
90
+ super().__init__(parent); self.setWindowTitle('Create a project'); self.resize(680,650)
91
+ layout=QVBoxLayout(self); layout.setContentsMargins(28,24,28,24); layout.setSpacing(12)
92
+ layout.addWidget(text('Make room for your next dataset.','cardTitle'))
93
+ self.name=QLineEdit(); self.name.setPlaceholderText('Project name'); layout.addWidget(self.name)
94
+ location=QHBoxLayout(); self.location=QLineEdit(); self.location.setPlaceholderText('Parent folder for the project')
95
+ location.addWidget(self.location); browse=QPushButton('Browse…'); browse.clicked.connect(self.browse); location.addWidget(browse); layout.addLayout(location)
96
+ self.pipelines=QButtonGroup(self); self.pipelines.setExclusive(True); grid=QGridLayout()
97
+ self.buttons={}
98
+ for i,(key,(title,description,_)) in enumerate(PIPELINES.items()):
99
+ b=pipeline_card(title,description); b.setCheckable(True)
100
+ self.pipelines.addButton(b); self.buttons[key]=b; grid.addWidget(b,i//2,i%2)
101
+ self.buttons[pipeline].setChecked(True); layout.addLayout(grid)
102
+ self.mode=QComboBox(); self.mode.addItem('Solo · just me','solo'); self.mode.addItem('Team · simultaneous reviewers on different computers','team')
103
+ layout.addWidget(text('Who is working on this project?')); layout.addWidget(self.mode)
104
+ self.classes=QTextEdit(); self.classes.setPlaceholderText('Classes / object labels, one per line\nYou can also add them later.'); self.classes.setMaximumHeight(100); layout.addWidget(self.classes)
105
+ layout.addWidget(text('Team mode uses this computer as the project host. You control who can connect.'))
106
+ buttons=QDialogButtonBox(QDialogButtonBox.Cancel); create=buttons.addButton('Create project',QDialogButtonBox.AcceptRole)
107
+ create.setObjectName('primary'); buttons.accepted.connect(self.validate); buttons.rejected.connect(self.reject); layout.addWidget(buttons)
108
+
109
+ def browse(self):
110
+ path=QFileDialog.getExistingDirectory(self,'Choose parent folder')
111
+ if path: self.location.setText(path)
112
+
113
+ def validate(self):
114
+ name=self.name.text().strip()
115
+ if not name or name in ('.','..') or any(c in name for c in '<>:"/\\|?*') or name.endswith(('.', ' ')):
116
+ QMessageBox.warning(self,'Project name','Enter a valid folder name.'); return
117
+ if not Path(self.location.text()).is_dir() or not self.location.text().strip():
118
+ QMessageBox.warning(self,'Project location','Choose an existing parent folder.'); return
119
+ if self.folder.exists() and any(self.folder.iterdir()):
120
+ QMessageBox.warning(self,'Folder already exists','Choose a new name; this folder is not empty.'); return
121
+ labels=[s.strip() for s in self.classes.toPlainText().splitlines() if s.strip()]
122
+ if len(labels)!=len(set(labels)) or any(len(s)>120 for s in labels):
123
+ QMessageBox.warning(self,'Labels','Use unique labels of 1–120 characters.'); return
124
+ self.accept()
125
+
126
+ @property
127
+ def folder(self): return Path(self.location.text())/self.name.text().strip()
128
+
129
+ @property
130
+ def pipeline(self): return next(key for key,b in self.buttons.items() if b.isChecked())
lannotex/models.py ADDED
@@ -0,0 +1,51 @@
1
+ from collections import OrderedDict
2
+ from pathlib import Path
3
+ from PySide6.QtCore import QAbstractListModel, QModelIndex, Qt, QSize
4
+ from PySide6.QtGui import QColor
5
+
6
+
7
+ class ImageListModel(QAbstractListModel):
8
+ """Virtual list: at most eight pages of 128 metadata records in memory."""
9
+ def __init__(self, parent=None):
10
+ super().__init__(parent)
11
+ self.project = None
12
+ self.search = ''
13
+ self.status = 'all'
14
+ self.total = 0
15
+ self.pages = OrderedDict()
16
+
17
+ def refresh(self, project=None, search=None, status=None):
18
+ self.beginResetModel()
19
+ if project is not None: self.project = project
20
+ if search is not None: self.search = search
21
+ if status is not None: self.status = status
22
+ self.pages.clear()
23
+ self.total = self.project.query(self.search,self.status,count=True) if self.project else 0
24
+ self.endResetModel()
25
+
26
+ def rowCount(self, parent=QModelIndex()): return 0 if parent.isValid() else self.total
27
+
28
+ def record(self, row):
29
+ if not 0 <= row < self.total: return None
30
+ page = row//128
31
+ if page not in self.pages:
32
+ self.pages[page] = self.project.query(self.search,self.status,offset=page*128)
33
+ while len(self.pages)>8: self.pages.popitem(last=False)
34
+ self.pages.move_to_end(page)
35
+ return self.pages[page][row%128]
36
+
37
+ def data(self, index, role=Qt.DisplayRole):
38
+ if not index.isValid(): return None
39
+ if role == Qt.SizeHintRole: return QSize(200,58)
40
+ row = self.record(index.row())
41
+ if row is None: return None
42
+ if role == Qt.DisplayRole:
43
+ state = {'new':'Unlabeled', 'done':'Labeled', 'empty':'No targets'}.get(row['status'],row['status'])
44
+ return f"{Path(row['path']).name}\n{state} · {row['width']} × {row['height']}"
45
+ if role == Qt.ToolTipRole: return row['path']
46
+ if role == Qt.ForegroundRole and row['status'] != 'new': return QColor('#888888')
47
+ if role == Qt.UserRole: return dict(row)
48
+
49
+ def invalidate(self):
50
+ self.pages.clear()
51
+ if self.total: self.dataChanged.emit(self.index(0),self.index(self.total-1))
lannotex/py.typed ADDED
File without changes
lannotex/storage.py ADDED
@@ -0,0 +1,241 @@
1
+ """Versioned SQLite projects. Every worker owns its connection."""
2
+ from __future__ import annotations
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import sqlite3
7
+ import tempfile
8
+ from .domain import Annotation, validate_all
9
+
10
+ # Migration 1 matches the shipped prototype; changes are appended.
11
+ MIGRATIONS = [
12
+ ["CREATE TABLE IF NOT EXISTS images(id INTEGER PRIMARY KEY, path TEXT UNIQUE NOT NULL, width INTEGER, height INTEGER, status TEXT NOT NULL DEFAULT 'new')",
13
+ "CREATE TABLE IF NOT EXISTS labels(name TEXT PRIMARY KEY, color TEXT NOT NULL DEFAULT '#38bdf8')",
14
+ "CREATE TABLE IF NOT EXISTS annotations(id TEXT PRIMARY KEY, image_id INTEGER NOT NULL, data TEXT NOT NULL, FOREIGN KEY(image_id) REFERENCES images(id))",
15
+ "CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY, value TEXT NOT NULL)"],
16
+ ["CREATE INDEX IF NOT EXISTS annotations_image ON annotations(image_id)",
17
+ "CREATE INDEX IF NOT EXISTS images_status ON images(status)",
18
+ "CREATE TABLE IF NOT EXISTS drafts(image_id INTEGER PRIMARY KEY REFERENCES images(id), data TEXT NOT NULL, negative INTEGER NOT NULL DEFAULT 0)"],
19
+ ["CREATE TABLE revisions(image_id INTEGER PRIMARY KEY REFERENCES images(id), revision INTEGER NOT NULL DEFAULT 0)",
20
+ "CREATE TABLE team_members(id TEXT PRIMARY KEY, name TEXT NOT NULL, token_hash TEXT UNIQUE NOT NULL, active INTEGER NOT NULL DEFAULT 1)",
21
+ "CREATE TABLE team_audit(id INTEGER PRIMARY KEY AUTOINCREMENT, image_id INTEGER NOT NULL, member TEXT NOT NULL, revision INTEGER NOT NULL, saved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)",
22
+ "CREATE TABLE team_changes(seq INTEGER PRIMARY KEY AUTOINCREMENT, image_id INTEGER NOT NULL)",
23
+ "CREATE TABLE team_links(image_id INTEGER PRIMARY KEY REFERENCES images(id), remote_revision INTEGER NOT NULL DEFAULT 0, dirty INTEGER NOT NULL DEFAULT 0, conflict INTEGER NOT NULL DEFAULT 0)",
24
+ "INSERT INTO revisions(image_id) SELECT id FROM images",
25
+ "INSERT INTO team_changes(image_id) SELECT id FROM images",
26
+ "CREATE TRIGGER image_added AFTER INSERT ON images BEGIN INSERT INTO revisions(image_id) VALUES(NEW.id); INSERT INTO team_changes(image_id) VALUES(NEW.id); END"],
27
+ ["ALTER TABLE team_audit ADD COLUMN data TEXT NOT NULL DEFAULT '[]'",
28
+ "ALTER TABLE team_audit ADD COLUMN negative INTEGER NOT NULL DEFAULT 0"],
29
+ ["ALTER TABLE drafts ADD COLUMN base_revision INTEGER"]
30
+ ]
31
+
32
+
33
+ def atomic_text(path, text):
34
+ path = Path(path)
35
+ path.parent.mkdir(parents=True, exist_ok=True)
36
+ fd, temp = tempfile.mkstemp(prefix='.lannotex-', dir=path.parent)
37
+ try:
38
+ with os.fdopen(fd, 'w', encoding='utf-8', newline='') as stream:
39
+ stream.write(text)
40
+ stream.flush()
41
+ os.fsync(stream.fileno())
42
+ os.replace(temp, path)
43
+ finally:
44
+ if os.path.exists(temp): os.unlink(temp)
45
+
46
+
47
+ class Project:
48
+ def __init__(self, root, create=True):
49
+ self.root = Path(root).resolve()
50
+ self.db_path = self.root / 'project.sqlite3'
51
+ if not create and not self.db_path.is_file(): raise FileNotFoundError('No project.sqlite3 in this folder')
52
+ self.root.mkdir(parents=True, exist_ok=True)
53
+ self.conn = sqlite3.connect(self.db_path, timeout=2)
54
+ self.conn.row_factory = sqlite3.Row
55
+ try:
56
+ self.conn.execute('PRAGMA foreign_keys=ON')
57
+ self.conn.execute('PRAGMA journal_mode=WAL')
58
+ self.conn.execute('PRAGMA synchronous=FULL')
59
+ self._migrate()
60
+ except Exception:
61
+ self.conn.close()
62
+ raise
63
+
64
+ def _migrate(self):
65
+ with self.conn:
66
+ self.conn.execute('BEGIN IMMEDIATE')
67
+ self.conn.execute('CREATE TABLE IF NOT EXISTS migrations(version INTEGER PRIMARY KEY)')
68
+ done = {r[0] for r in self.conn.execute('SELECT version FROM migrations')}
69
+ if done and max(done) > len(MIGRATIONS): raise ValueError('Project requires a newer Lannotex version')
70
+ for version, statements in enumerate(MIGRATIONS, 1):
71
+ if version not in done:
72
+ for sql in statements: self.conn.execute(sql)
73
+ self.conn.execute('INSERT INTO migrations VALUES(?)', (version,))
74
+
75
+ def close(self): self.conn.close()
76
+ def labels(self): return self.conn.execute('SELECT name,color FROM labels ORDER BY name').fetchall()
77
+
78
+ def add_label(self, name, color='#37b8a5'):
79
+ name = name.strip()
80
+ if not name or len(name) > 120: raise ValueError('Enter a label of 1–120 characters')
81
+ with self.conn: self.conn.execute('INSERT INTO labels VALUES(?,?)', (name, color))
82
+
83
+ def rename_label(self, old, new):
84
+ new = new.strip()
85
+ if not new or len(new) > 120: raise ValueError('Enter a label of 1–120 characters')
86
+ with self.conn:
87
+ self.conn.execute('UPDATE labels SET name=? WHERE name=?', (new, old))
88
+ affected=set()
89
+ for row in self.conn.execute('SELECT id,image_id,data FROM annotations').fetchall():
90
+ data = json.loads(row['data'])
91
+ if data['label'] == old:
92
+ data['label'] = new
93
+ self.conn.execute('UPDATE annotations SET data=? WHERE id=?', (json.dumps(data), row['id']))
94
+ affected.add(row['image_id'])
95
+ for row in self.conn.execute('SELECT image_id,data FROM drafts').fetchall():
96
+ data = json.loads(row['data'])
97
+ for a in data:
98
+ if a['label'] == old: a['label'] = new
99
+ self.conn.execute('UPDATE drafts SET data=? WHERE image_id=?', (json.dumps(data), row['image_id']))
100
+ for ident in affected:
101
+ self.conn.execute('UPDATE revisions SET revision=revision+1 WHERE image_id=?',(ident,))
102
+ self.conn.execute('INSERT INTO team_changes(image_id) VALUES(?)',(ident,))
103
+
104
+ def remove_label(self, name):
105
+ for row in self.conn.execute('SELECT data FROM annotations UNION ALL SELECT data FROM drafts'):
106
+ data = json.loads(row[0])
107
+ if any(a['label'] == name for a in (data if isinstance(data, list) else [data])):
108
+ raise ValueError('This label is in use. Rename it, or relabel its annotations first.')
109
+ with self.conn: self.conn.execute('DELETE FROM labels WHERE name=?', (name,))
110
+
111
+ def reference(self, path):
112
+ path = Path(path).resolve()
113
+ try: return os.path.relpath(path, self.root)
114
+ except ValueError: return str(path)
115
+
116
+ def resolve(self, reference): return (self.root / reference).resolve()
117
+
118
+ def add_image(self, path, width=0, height=0):
119
+ path = self.reference(path)
120
+ with self.conn: self.conn.execute('INSERT OR IGNORE INTO images(path,width,height) VALUES(?,?,?)', (path, width, height))
121
+ return self.conn.execute('SELECT id FROM images WHERE path=?', (path,)).fetchone()[0]
122
+
123
+ def add_batch(self, records):
124
+ with self.conn:
125
+ inserted = self.conn.executemany('INSERT OR IGNORE INTO images(path,width,height) VALUES(?,?,?)', records)
126
+ return inserted.rowcount
127
+
128
+ def images(self): return self.conn.execute('SELECT * FROM images ORDER BY path').fetchall()
129
+ def image(self, ident): return self.conn.execute('SELECT * FROM images WHERE id=?', (ident,)).fetchone()
130
+
131
+ def query(self, search='', status='all', offset=0, limit=128, count=False):
132
+ where, args = [], []
133
+ if search:
134
+ where.append("path LIKE ? ESCAPE '\\'")
135
+ args.append('%'+search.replace('\\','\\\\').replace('%','\\%').replace('_','\\_')+'%')
136
+ if status != 'all': where.append('status=?'); args.append(status)
137
+ sql = 'SELECT '+('COUNT(*)' if count else '*')+' FROM images'
138
+ if where: sql += ' WHERE '+' AND '.join(where)
139
+ if not count: sql += ' ORDER BY path LIMIT ? OFFSET ?'; args.extend([limit, offset])
140
+ result = self.conn.execute(sql, args)
141
+ return result.fetchone()[0] if count else result.fetchall()
142
+
143
+ def load_annotations(self, image_id):
144
+ return [Annotation.from_dict(json.loads(r[0])) for r in self.conn.execute('SELECT data FROM annotations WHERE image_id=? ORDER BY rowid', (image_id,))]
145
+
146
+ def load_state(self, image_id):
147
+ draft = self.conn.execute('SELECT * FROM drafts WHERE image_id=?', (image_id,)).fetchone()
148
+ if draft: return [Annotation.from_dict(a) for a in json.loads(draft['data'])], bool(draft['negative']), True
149
+ return self.load_annotations(image_id), self.image(image_id)['status'] == 'empty', False
150
+
151
+ def load_document(self, image_id):
152
+ with self.conn:
153
+ self.conn.execute('BEGIN')
154
+ annotations,negative,recovered=self.load_state(image_id)
155
+ revision=self.conn.execute('SELECT revision FROM revisions WHERE image_id=?',(image_id,)).fetchone()[0]
156
+ draft=self.conn.execute('SELECT base_revision FROM drafts WHERE image_id=?',(image_id,)).fetchone()
157
+ if draft and draft[0] is not None: revision=draft[0]
158
+ return annotations,negative,recovered,revision
159
+
160
+ def save_draft(self, image_id, annotations, negative=False, base_revision=None):
161
+ with self.conn:
162
+ if base_revision is None:
163
+ base_revision=self.conn.execute('SELECT revision FROM revisions WHERE image_id=?',(image_id,)).fetchone()[0]
164
+ self.conn.execute('INSERT INTO drafts(image_id,data,negative,base_revision) VALUES(?,?,?,?) '
165
+ 'ON CONFLICT(image_id) DO UPDATE SET data=excluded.data,negative=excluded.negative',
166
+ (image_id, json.dumps([a.to_dict() for a in annotations]), int(negative),base_revision))
167
+
168
+ def save_annotations(self, image_id, annotations, negative=False, actor='Local owner', expected_revision=None, clear_draft=True):
169
+ errors = validate_all(annotations, {r['name'] for r in self.labels()}, negative)
170
+ if errors: raise ValueError('; '.join(errors))
171
+ payload = [(a.id, image_id, json.dumps(a.to_dict(), separators=(',', ':'))) for a in annotations]
172
+ with self.conn:
173
+ self.conn.execute('BEGIN IMMEDIATE')
174
+ current=self.conn.execute('SELECT revision FROM revisions WHERE image_id=?',(image_id,)).fetchone()
175
+ if current is None: raise ValueError('Image does not exist')
176
+ if expected_revision is not None and current[0]!=expected_revision: raise ConflictError('Another reviewer saved this image. Reload or resolve the conflict before saving.')
177
+ self.conn.execute('DELETE FROM annotations WHERE image_id=?', (image_id,))
178
+ self.conn.executemany('INSERT INTO annotations VALUES(?,?,?)', payload)
179
+ self.conn.execute('UPDATE images SET status=? WHERE id=?', ('empty' if negative else 'done' if annotations else 'new', image_id))
180
+ if clear_draft: self.conn.execute('DELETE FROM drafts WHERE image_id=?', (image_id,))
181
+ self.conn.execute('UPDATE revisions SET revision=revision+1 WHERE image_id=?',(image_id,))
182
+ self.conn.execute('INSERT INTO team_changes(image_id) VALUES(?)',(image_id,))
183
+ self.conn.execute('INSERT INTO team_audit(image_id,member,revision,data,negative) VALUES(?,?,?,?,?)',
184
+ (image_id,actor,current[0]+1,json.dumps([a.to_dict() for a in annotations]),int(negative)))
185
+ if self.setting('team_remote'):
186
+ self.conn.execute('UPDATE team_links SET dirty=1 WHERE image_id=?',(image_id,))
187
+
188
+ def setting(self, key, default=None):
189
+ row = self.conn.execute('SELECT value FROM settings WHERE key=?', (key,)).fetchone()
190
+ return json.loads(row[0]) if row else default
191
+
192
+ def set_setting(self, key, value):
193
+ with self.conn: self.conn.execute('INSERT OR REPLACE INTO settings VALUES(?,?)', (key, json.dumps(value)))
194
+
195
+ def relink(self, image_id, path, width, height):
196
+ old = self.image(image_id)
197
+ if (old['width'], old['height']) != (width, height): raise ValueError('Replacement dimensions differ; annotations would be misaligned')
198
+ with self.conn: self.conn.execute('UPDATE images SET path=? WHERE id=?', (self.reference(path), image_id))
199
+
200
+ def backup(self, target):
201
+ target = Path(target).resolve()
202
+ if target == self.db_path: raise ValueError('Choose a different backup path')
203
+ target.parent.mkdir(parents=True, exist_ok=True)
204
+ fd, temp = tempfile.mkstemp(dir=target.parent, suffix='.sqlite3'); os.close(fd)
205
+ try:
206
+ out = sqlite3.connect(temp)
207
+ try:
208
+ self.conn.backup(out)
209
+ out.execute('INSERT OR REPLACE INTO settings VALUES(?,?)',('backup_source_root',json.dumps(str(self.root))))
210
+ out.commit()
211
+ finally: out.close()
212
+ os.replace(temp, target)
213
+ finally:
214
+ if os.path.exists(temp): os.unlink(temp)
215
+
216
+ @classmethod
217
+ def restore(cls, source, folder):
218
+ destination = Path(folder) / 'project.sqlite3'
219
+ if destination.exists(): raise ValueError('Restore requires a folder without an existing project')
220
+ source = Path(source).resolve()
221
+ check = sqlite3.connect(source.as_uri()+'?mode=ro', uri=True)
222
+ try:
223
+ if check.execute('PRAGMA integrity_check').fetchone()[0] != 'ok': raise ValueError('Backup is damaged')
224
+ check.execute('SELECT version FROM migrations').fetchall()
225
+ Path(folder).mkdir(parents=True, exist_ok=True)
226
+ out = sqlite3.connect(destination)
227
+ try: check.backup(out)
228
+ finally: out.close()
229
+ finally: check.close()
230
+ restored=cls(folder,create=False)
231
+ old_root=restored.setting('backup_source_root')
232
+ if old_root:
233
+ with restored.conn:
234
+ for row in restored.images():
235
+ old_path=Path(old_root)/row['path']
236
+ restored.conn.execute('UPDATE images SET path=? WHERE id=?',(restored.reference(old_path),row['id']))
237
+ return restored
238
+
239
+
240
+ class ConflictError(ValueError):
241
+ """A stale edit must never replace a newer shared revision."""