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/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."""
|