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/canvas.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Native canvas. Geometry lives in the document, never in disposable graphics items."""
|
|
2
|
+
from copy import deepcopy
|
|
3
|
+
import math
|
|
4
|
+
from PySide6.QtCore import QPointF, QRectF, Qt, Signal
|
|
5
|
+
from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPolygonF
|
|
6
|
+
from PySide6.QtWidgets import QWidget
|
|
7
|
+
from .domain import Annotation, validate
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Canvas(QWidget):
|
|
11
|
+
edited = Signal(object)
|
|
12
|
+
selected = Signal(object)
|
|
13
|
+
zoomed = Signal(int)
|
|
14
|
+
message = Signal(str)
|
|
15
|
+
|
|
16
|
+
def __init__(self, parent=None):
|
|
17
|
+
super().__init__(parent)
|
|
18
|
+
self.setFocusPolicy(Qt.StrongFocus)
|
|
19
|
+
self.setMouseTracking(True)
|
|
20
|
+
self.setMinimumSize(280, 200)
|
|
21
|
+
self.setAccessibleName('Image annotation canvas')
|
|
22
|
+
self.image = None
|
|
23
|
+
self.annotations = []
|
|
24
|
+
self.colors = {}
|
|
25
|
+
self.label = ''
|
|
26
|
+
self.mode = 'select'
|
|
27
|
+
self.selection = None
|
|
28
|
+
self.vertex = None
|
|
29
|
+
self.opacity = .15
|
|
30
|
+
self.zoom = 1.
|
|
31
|
+
self.pan = QPointF()
|
|
32
|
+
self.points = []
|
|
33
|
+
self.drag = None
|
|
34
|
+
self.preview = None
|
|
35
|
+
self.redraw_id = None
|
|
36
|
+
self.space = False
|
|
37
|
+
self.empty_title = 'Your next dataset starts here'
|
|
38
|
+
self.empty_detail = 'Create or open a project, then import your images.'
|
|
39
|
+
|
|
40
|
+
def set_image(self, image):
|
|
41
|
+
self.cancel()
|
|
42
|
+
self.image = image
|
|
43
|
+
self.fit()
|
|
44
|
+
|
|
45
|
+
def set_annotations(self, annotations):
|
|
46
|
+
self.annotations = deepcopy(annotations)
|
|
47
|
+
if not any(a.id == self.selection for a in annotations): self.select(None)
|
|
48
|
+
self.update()
|
|
49
|
+
|
|
50
|
+
def set_mode(self, mode):
|
|
51
|
+
self.cancel()
|
|
52
|
+
self.mode = mode
|
|
53
|
+
self.setCursor(Qt.CrossCursor if mode in ('box', 'polygon') else Qt.ArrowCursor)
|
|
54
|
+
self.setFocus()
|
|
55
|
+
|
|
56
|
+
def select(self, ident):
|
|
57
|
+
self.selection = ident
|
|
58
|
+
self.vertex = None
|
|
59
|
+
self.selected.emit(ident)
|
|
60
|
+
self.update()
|
|
61
|
+
|
|
62
|
+
def fit(self):
|
|
63
|
+
self.zoom = 1.
|
|
64
|
+
self.pan = QPointF()
|
|
65
|
+
self.zoomed.emit(100)
|
|
66
|
+
self.update()
|
|
67
|
+
|
|
68
|
+
def image_rect(self):
|
|
69
|
+
if self.image is None: return QRectF()
|
|
70
|
+
w, h = self.image.width(), self.image.height()
|
|
71
|
+
scale = min(max(1, self.width()-64)/w, max(1, self.height()-64)/h)*self.zoom
|
|
72
|
+
return QRectF((self.width()-w*scale)/2+self.pan.x(), (self.height()-h*scale)/2+self.pan.y(), w*scale, h*scale)
|
|
73
|
+
|
|
74
|
+
def screen(self, p):
|
|
75
|
+
r = self.image_rect()
|
|
76
|
+
return QPointF(r.left()+p[0]*r.width(), r.top()+p[1]*r.height())
|
|
77
|
+
|
|
78
|
+
def normalized(self, p):
|
|
79
|
+
r = self.image_rect()
|
|
80
|
+
return [max(0., min(1., (p.x()-r.left())/max(1,r.width()))), max(0., min(1., (p.y()-r.top())/max(1,r.height())))]
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def vertices(a):
|
|
84
|
+
if a.kind == 'polygon': return a.geometry
|
|
85
|
+
if a.kind == 'box':
|
|
86
|
+
x,y,r,b = a.geometry
|
|
87
|
+
return [[x,y],[r,y],[r,b],[x,b]]
|
|
88
|
+
return []
|
|
89
|
+
|
|
90
|
+
def sized_font(self, points, weight=QFont.Normal):
|
|
91
|
+
font = QFont(self.font())
|
|
92
|
+
font.setPointSize(points)
|
|
93
|
+
font.setWeight(weight)
|
|
94
|
+
return font
|
|
95
|
+
|
|
96
|
+
def paintEvent(self, event):
|
|
97
|
+
painter = QPainter(self)
|
|
98
|
+
painter.fillRect(self.rect(), self.palette().base())
|
|
99
|
+
painter.setRenderHint(QPainter.Antialiasing)
|
|
100
|
+
if self.image is None:
|
|
101
|
+
painter.setPen(self.palette().text().color())
|
|
102
|
+
painter.setFont(self.sized_font(20, QFont.DemiBold))
|
|
103
|
+
painter.drawText(self.rect().adjusted(24,-30,-24,-30), Qt.AlignCenter, self.empty_title)
|
|
104
|
+
painter.setFont(self.sized_font(10))
|
|
105
|
+
painter.drawText(self.rect().adjusted(24,50,-24,50), Qt.AlignCenter | Qt.TextWordWrap, self.empty_detail)
|
|
106
|
+
return
|
|
107
|
+
r = self.image_rect()
|
|
108
|
+
painter.setRenderHint(QPainter.SmoothPixmapTransform, self.zoom < 2)
|
|
109
|
+
painter.drawImage(r, self.image)
|
|
110
|
+
painter.setClipRect(r)
|
|
111
|
+
shown = self.preview if self.preview is not None else self.annotations
|
|
112
|
+
for n, a in enumerate(shown, 1):
|
|
113
|
+
pts = self.vertices(a)
|
|
114
|
+
if not pts: continue
|
|
115
|
+
color = QColor(self.colors.get(a.label, '#38bfa7'))
|
|
116
|
+
selected = a.id == self.selection
|
|
117
|
+
painter.setPen(QPen(color, 2.4 if selected else 1.5))
|
|
118
|
+
fill = QColor(color); fill.setAlphaF(self.opacity)
|
|
119
|
+
painter.setBrush(fill)
|
|
120
|
+
poly = QPolygonF([self.screen(p) for p in pts])
|
|
121
|
+
painter.drawPolygon(poly)
|
|
122
|
+
anchor = poly.boundingRect().topLeft()
|
|
123
|
+
text = f'{n} {a.label}'
|
|
124
|
+
painter.setFont(self.sized_font(9, QFont.DemiBold))
|
|
125
|
+
width = painter.fontMetrics().horizontalAdvance(text)+14
|
|
126
|
+
badge = QRectF(anchor.x(), max(r.top(), anchor.y()-23), width, 22)
|
|
127
|
+
painter.fillRect(badge, color)
|
|
128
|
+
painter.setPen(QColor('#101820'))
|
|
129
|
+
painter.drawText(badge, Qt.AlignCenter, text)
|
|
130
|
+
if selected:
|
|
131
|
+
for i, p in enumerate(poly):
|
|
132
|
+
painter.setPen(QPen(color, 1.5))
|
|
133
|
+
painter.setBrush(QColor('#f4f8fc') if i != self.vertex else QColor('#f4ba58'))
|
|
134
|
+
painter.drawRect(QRectF(p.x()-4,p.y()-4,8,8))
|
|
135
|
+
painter.setClipping(False)
|
|
136
|
+
if self.points:
|
|
137
|
+
painter.setPen(QPen(QColor('#f4ba58'), 2, Qt.DashLine))
|
|
138
|
+
painter.setBrush(Qt.NoBrush)
|
|
139
|
+
painter.drawPolyline(QPolygonF([self.screen(p) for p in self.points]))
|
|
140
|
+
for p in self.points:
|
|
141
|
+
painter.setBrush(QColor('#f4ba58')); painter.drawEllipse(self.screen(p), 3.5, 3.5)
|
|
142
|
+
|
|
143
|
+
def wheelEvent(self, event):
|
|
144
|
+
if self.image is None: return
|
|
145
|
+
before = self.image_rect()
|
|
146
|
+
pos = event.position()
|
|
147
|
+
u = (pos.x()-before.x())/before.width()
|
|
148
|
+
v = (pos.y()-before.y())/before.height()
|
|
149
|
+
self.zoom = max(.1, min(32., self.zoom * (1.15 if event.angleDelta().y()>0 else 1/1.15)))
|
|
150
|
+
after = self.image_rect()
|
|
151
|
+
self.pan += QPointF(pos.x()-(after.x()+u*after.width()), pos.y()-(after.y()+v*after.height()))
|
|
152
|
+
self.zoomed.emit(round(self.zoom*100))
|
|
153
|
+
self.update()
|
|
154
|
+
|
|
155
|
+
def mousePressEvent(self, event):
|
|
156
|
+
self.setFocus()
|
|
157
|
+
if self.image is None: return
|
|
158
|
+
if event.button() == Qt.MiddleButton or (self.space and event.button() == Qt.LeftButton):
|
|
159
|
+
self.drag = {'kind':'pan','start':event.position(),'pan':QPointF(self.pan)}
|
|
160
|
+
self.setCursor(Qt.ClosedHandCursor)
|
|
161
|
+
return
|
|
162
|
+
if event.button() != Qt.LeftButton or not self.image_rect().contains(event.position()): return
|
|
163
|
+
p = self.normalized(event.position())
|
|
164
|
+
if self.mode in ('box','polygon'):
|
|
165
|
+
if not self.label:
|
|
166
|
+
self.message.emit('Add and select a label before drawing.'); return
|
|
167
|
+
if self.mode == 'polygon':
|
|
168
|
+
if len(self.points) >= 1000: self.message.emit('A polygon supports up to 1000 vertices.'); return
|
|
169
|
+
self.points.append(p); self.update(); return
|
|
170
|
+
self.drag = {'kind':'draw','start':p}
|
|
171
|
+
return
|
|
172
|
+
chosen = next((a for a in self.annotations if a.id == self.selection), None)
|
|
173
|
+
if chosen:
|
|
174
|
+
for i, vertex in enumerate(self.vertices(chosen)):
|
|
175
|
+
q = self.screen(vertex)
|
|
176
|
+
if math.hypot(q.x()-event.position().x(),q.y()-event.position().y()) < 10:
|
|
177
|
+
self.vertex = i
|
|
178
|
+
self.drag = {'kind':'vertex','start':p,'original':deepcopy(chosen),'vertex':i}
|
|
179
|
+
self.update(); return
|
|
180
|
+
for a in reversed(self.annotations):
|
|
181
|
+
if QPolygonF([self.screen(v) for v in self.vertices(a)]).containsPoint(event.position(), Qt.OddEvenFill):
|
|
182
|
+
self.select(a.id)
|
|
183
|
+
self.drag = {'kind':'move','start':p,'original':deepcopy(a)}
|
|
184
|
+
return
|
|
185
|
+
self.select(None)
|
|
186
|
+
|
|
187
|
+
def mouseMoveEvent(self, event):
|
|
188
|
+
if not self.drag: return
|
|
189
|
+
d = self.drag
|
|
190
|
+
if d['kind'] == 'pan':
|
|
191
|
+
self.pan = d['pan']+event.position()-d['start']; self.update(); return
|
|
192
|
+
p = self.normalized(event.position())
|
|
193
|
+
if d['kind'] == 'draw':
|
|
194
|
+
x,y = d['start']
|
|
195
|
+
a = Annotation(self.label, 'box', [min(x,p[0]),min(y,p[1]),max(x,p[0]),max(y,p[1])])
|
|
196
|
+
if self.redraw_id: a.id = self.redraw_id
|
|
197
|
+
self.preview = [b for b in self.annotations if b.id != a.id]+[a]
|
|
198
|
+
else:
|
|
199
|
+
a = deepcopy(d['original'])
|
|
200
|
+
if d['kind'] == 'move':
|
|
201
|
+
pts = self.vertices(a)
|
|
202
|
+
dx = max(-min(v[0] for v in pts),min(1-max(v[0] for v in pts),p[0]-d['start'][0]))
|
|
203
|
+
dy = max(-min(v[1] for v in pts),min(1-max(v[1] for v in pts),p[1]-d['start'][1]))
|
|
204
|
+
if a.kind == 'box': a.geometry = [a.geometry[0]+dx,a.geometry[1]+dy,a.geometry[2]+dx,a.geometry[3]+dy]
|
|
205
|
+
else: a.geometry = [[v[0]+dx,v[1]+dy] for v in pts]
|
|
206
|
+
elif a.kind == 'polygon': a.geometry[d['vertex']] = p
|
|
207
|
+
else:
|
|
208
|
+
opposite = self.vertices(a)[(d['vertex']+2)%4]
|
|
209
|
+
a.geometry = [min(p[0],opposite[0]),min(p[1],opposite[1]),max(p[0],opposite[0]),max(p[1],opposite[1])]
|
|
210
|
+
self.preview = [a if b.id == a.id else b for b in self.annotations]
|
|
211
|
+
self.update()
|
|
212
|
+
|
|
213
|
+
def mouseReleaseEvent(self, event):
|
|
214
|
+
if not self.drag: return
|
|
215
|
+
kind = self.drag['kind']
|
|
216
|
+
self.drag = None
|
|
217
|
+
if kind == 'pan': self.setCursor(Qt.OpenHandCursor if self.space else Qt.ArrowCursor); return
|
|
218
|
+
if self.preview is not None:
|
|
219
|
+
errors = [error for a in self.preview for error in validate(a,set(self.colors))]
|
|
220
|
+
if errors: self.message.emit(errors[0])
|
|
221
|
+
else:
|
|
222
|
+
if kind == 'draw': self.selection = self.preview[-1].id
|
|
223
|
+
self.edited.emit(deepcopy(self.preview))
|
|
224
|
+
self.selected.emit(self.selection)
|
|
225
|
+
self.redraw_id = None
|
|
226
|
+
self.preview = None
|
|
227
|
+
self.update()
|
|
228
|
+
|
|
229
|
+
def finish_polygon(self):
|
|
230
|
+
if not self.points: return
|
|
231
|
+
a = Annotation(self.label,'polygon',deepcopy(self.points))
|
|
232
|
+
if self.redraw_id: a.id = self.redraw_id
|
|
233
|
+
errors = validate(a, set(self.colors))
|
|
234
|
+
if errors: self.message.emit(errors[0]); return
|
|
235
|
+
self.selection = a.id
|
|
236
|
+
self.edited.emit([b for b in self.annotations if b.id != a.id]+[a])
|
|
237
|
+
self.selected.emit(a.id)
|
|
238
|
+
self.points = []; self.redraw_id = None; self.update()
|
|
239
|
+
|
|
240
|
+
def mouseDoubleClickEvent(self, event):
|
|
241
|
+
if self.mode != 'select' or self.image is None: return
|
|
242
|
+
a = next((deepcopy(x) for x in self.annotations if x.id == self.selection and x.kind == 'polygon'), None)
|
|
243
|
+
if a is None: return
|
|
244
|
+
pos = event.position(); best = None
|
|
245
|
+
for i, (p,q) in enumerate(zip(a.geometry,a.geometry[1:]+a.geometry[:1])):
|
|
246
|
+
u,v = self.screen(p),self.screen(q)
|
|
247
|
+
dx,dy = v.x()-u.x(),v.y()-u.y()
|
|
248
|
+
t = max(0,min(1,((pos.x()-u.x())*dx+(pos.y()-u.y())*dy)/max(1e-8,dx*dx+dy*dy)))
|
|
249
|
+
distance = math.hypot(pos.x()-u.x()-t*dx,pos.y()-u.y()-t*dy)
|
|
250
|
+
if best is None or distance < best[0]: best = (distance,i,[p[0]+t*(q[0]-p[0]),p[1]+t*(q[1]-p[1])])
|
|
251
|
+
if best and best[0] < 12:
|
|
252
|
+
a.geometry.insert(best[1]+1,best[2])
|
|
253
|
+
if not validate(a,set(self.colors)): self.edited.emit([a if b.id==a.id else b for b in self.annotations])
|
|
254
|
+
self.drag = None
|
|
255
|
+
|
|
256
|
+
def delete_vertex(self):
|
|
257
|
+
a = next((deepcopy(x) for x in self.annotations if x.id == self.selection and x.kind=='polygon'), None)
|
|
258
|
+
if a is None or self.vertex is None: return
|
|
259
|
+
if len(a.geometry) <= 3: self.message.emit('A polygon must retain at least three vertices.'); return
|
|
260
|
+
a.geometry.pop(self.vertex)
|
|
261
|
+
errors = validate(a,set(self.colors))
|
|
262
|
+
if errors: self.message.emit(errors[0]); return
|
|
263
|
+
self.vertex = None
|
|
264
|
+
self.edited.emit([a if b.id==a.id else b for b in self.annotations])
|
|
265
|
+
|
|
266
|
+
def redraw(self):
|
|
267
|
+
a = next((x for x in self.annotations if x.id == self.selection and x.kind != 'classification'), None)
|
|
268
|
+
if a:
|
|
269
|
+
self.set_mode(a.kind); self.redraw_id = a.id; self.label = a.label
|
|
270
|
+
self.message.emit('Draw the replacement. Escape keeps the original annotation.')
|
|
271
|
+
|
|
272
|
+
def cancel(self):
|
|
273
|
+
self.points = []; self.drag = None; self.preview = None; self.redraw_id = None
|
|
274
|
+
self.update()
|
|
275
|
+
|
|
276
|
+
def keyPressEvent(self, event):
|
|
277
|
+
if event.key() == Qt.Key_Space:
|
|
278
|
+
self.space = True; self.setCursor(Qt.OpenHandCursor); event.accept(); return
|
|
279
|
+
super().keyPressEvent(event)
|
|
280
|
+
|
|
281
|
+
def keyReleaseEvent(self, event):
|
|
282
|
+
if event.key() == Qt.Key_Space:
|
|
283
|
+
self.space = False; self.setCursor(Qt.ArrowCursor); event.accept(); return
|
|
284
|
+
super().keyReleaseEvent(event)
|
|
285
|
+
|
|
286
|
+
def focusOutEvent(self, event):
|
|
287
|
+
self.space = False
|
|
288
|
+
if self.drag: self.cancel()
|
|
289
|
+
super().focusOutEvent(event)
|
lannotex/cli.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Lannotex command-line interface.
|
|
2
|
+
|
|
3
|
+
Running bare ``lannotex`` launches the desktop application. Subcommands
|
|
4
|
+
(``export``, ``analyze``, ``new``) run headlessly for automation and CI and
|
|
5
|
+
do not import Qt.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import threading
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
|
|
16
|
+
EXPORT_FORMATS = ['Labelme JSON', 'YOLO boxes', 'COCO JSON', 'Classification CSV', 'Native JSONL']
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _progress(value, message):
|
|
20
|
+
print(f'[{value:3d}%] {message}', file=sys.stderr)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _cancel():
|
|
24
|
+
return threading.Event()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_parser():
|
|
28
|
+
parser = argparse.ArgumentParser(
|
|
29
|
+
prog='lannotex',
|
|
30
|
+
description='Fast, offline-first image annotation for classifications, boxes, and polygons.',
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument('--version', action='version', version=f'%(prog)s {__version__}')
|
|
33
|
+
sub = parser.add_subparsers(dest='command')
|
|
34
|
+
|
|
35
|
+
gui = sub.add_parser('gui', help='launch the desktop application (this is the default)')
|
|
36
|
+
gui.add_argument('--smoke-test', action='store_true', help=argparse.SUPPRESS)
|
|
37
|
+
|
|
38
|
+
exp = sub.add_parser('export', help='export a project headlessly to a local directory')
|
|
39
|
+
exp.add_argument('project', help='project folder that contains project.sqlite3')
|
|
40
|
+
exp.add_argument('destination', help='directory that receives the exported files')
|
|
41
|
+
exp.add_argument('--format', choices=EXPORT_FORMATS, default='Labelme JSON', help='export format (default: %(default)s)')
|
|
42
|
+
|
|
43
|
+
ana = sub.add_parser('analyze', help='validate a project and print a health report as JSON')
|
|
44
|
+
ana.add_argument('project', help='project folder that contains project.sqlite3')
|
|
45
|
+
|
|
46
|
+
proj = sub.add_parser('new', help='create a new empty project folder')
|
|
47
|
+
proj.add_argument('folder', help='folder to initialize as a project')
|
|
48
|
+
|
|
49
|
+
host = sub.add_parser('host', help='serve a team project from this computer')
|
|
50
|
+
host.add_argument('project')
|
|
51
|
+
host.add_argument('--bind', default='127.0.0.1', help='use 0.0.0.0 to accept LAN connections')
|
|
52
|
+
host.add_argument('--port', type=int, default=8765)
|
|
53
|
+
host.add_argument('--cert', help='TLS certificate PEM')
|
|
54
|
+
host.add_argument('--key', help='TLS private key PEM')
|
|
55
|
+
invite = sub.add_parser('invite', help='create a reviewer token on a project host')
|
|
56
|
+
invite.add_argument('project')
|
|
57
|
+
invite.add_argument('name')
|
|
58
|
+
|
|
59
|
+
return parser
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main(argv=None):
|
|
63
|
+
args = build_parser().parse_args(argv)
|
|
64
|
+
if args.command in (None, 'gui'):
|
|
65
|
+
from .app import main as gui_main
|
|
66
|
+
return gui_main()
|
|
67
|
+
if args.command == 'export':
|
|
68
|
+
from .exporters import export_project
|
|
69
|
+
result = export_project(args.project, args.destination, args.format, _cancel(), _progress)
|
|
70
|
+
print(f"Exported {result['included']} images to {result['path']}")
|
|
71
|
+
if result['errors']:
|
|
72
|
+
print('Errors:', file=sys.stderr)
|
|
73
|
+
print('\n'.join(result['errors']), file=sys.stderr)
|
|
74
|
+
return 0
|
|
75
|
+
if args.command == 'analyze':
|
|
76
|
+
from .exporters import analyze
|
|
77
|
+
result = analyze(args.project, _cancel(), _progress)
|
|
78
|
+
print(json.dumps(result, indent=2))
|
|
79
|
+
return 0
|
|
80
|
+
if args.command == 'new':
|
|
81
|
+
from .storage import Project
|
|
82
|
+
Project(args.folder, create=True).close()
|
|
83
|
+
print(f'Created project at {Path(args.folder).resolve()}')
|
|
84
|
+
return 0
|
|
85
|
+
if args.command == 'invite':
|
|
86
|
+
from .team import add_member
|
|
87
|
+
print(add_member(args.project, args.name))
|
|
88
|
+
return 0
|
|
89
|
+
if args.command == 'host':
|
|
90
|
+
from .team import TeamServer
|
|
91
|
+
server = TeamServer(args.project,args.bind,args.port,args.cert,args.key)
|
|
92
|
+
print(f"Team host listening on {args.bind}:{server.server_address[1]}. Ctrl+C stops hosting.")
|
|
93
|
+
try: server.serve_forever()
|
|
94
|
+
except KeyboardInterrupt: pass
|
|
95
|
+
finally: server.server_close()
|
|
96
|
+
return 0
|
|
97
|
+
return 0
|
lannotex/domain.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Annotation invariants and reversible document changes; no Qt or file I/O."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from dataclasses import asdict, dataclass, field
|
|
5
|
+
import math
|
|
6
|
+
import uuid
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Annotation:
|
|
11
|
+
label: str
|
|
12
|
+
kind: str
|
|
13
|
+
geometry: list = field(default_factory=list)
|
|
14
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
15
|
+
|
|
16
|
+
def to_dict(self):
|
|
17
|
+
return asdict(self)
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def from_dict(cls, data):
|
|
21
|
+
return cls(data['label'], data['kind'], deepcopy(data.get('geometry', [])), data['id'])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def finite_unit(n):
|
|
25
|
+
return isinstance(n, (float, int)) and not isinstance(n, bool) and math.isfinite(n) and 0 <= n <= 1
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def box_valid(box):
|
|
29
|
+
return (isinstance(box, list) and len(box) == 4 and all(finite_unit(n) for n in box)
|
|
30
|
+
and box[2] - box[0] > 1e-8 and box[3] - box[1] > 1e-8)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def polygon_area(points):
|
|
34
|
+
return abs(sum(a[0]*b[1] - b[0]*a[1] for a, b in zip(points, points[1:]+points[:1]))) / 2
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def polygon_self_intersects(points):
|
|
38
|
+
def cross(a, b, c):
|
|
39
|
+
return (b[0]-a[0])*(c[1]-a[1]) - (b[1]-a[1])*(c[0]-a[0])
|
|
40
|
+
|
|
41
|
+
def on(a, b, c):
|
|
42
|
+
return (abs(cross(a, b, c)) < 1e-12 and min(a[0], b[0])-1e-12 <= c[0] <= max(a[0], b[0])+1e-12
|
|
43
|
+
and min(a[1], b[1])-1e-12 <= c[1] <= max(a[1], b[1])+1e-12)
|
|
44
|
+
|
|
45
|
+
n = len(points)
|
|
46
|
+
for i in range(n):
|
|
47
|
+
a, b = points[i], points[(i+1) % n]
|
|
48
|
+
for j in range(i+1, n):
|
|
49
|
+
if (i+1) % n == j or (j+1) % n == i: continue
|
|
50
|
+
c, d = points[j], points[(j+1) % n]
|
|
51
|
+
if (cross(a,b,c)*cross(a,b,d) < 0 and cross(c,d,a)*cross(c,d,b) < 0
|
|
52
|
+
or on(a,b,c) or on(a,b,d) or on(c,d,a) or on(c,d,b)):
|
|
53
|
+
return True
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validate(a, labels):
|
|
58
|
+
errors = []
|
|
59
|
+
if not isinstance(a.id, str) or not a.id.strip(): errors.append('Missing annotation ID')
|
|
60
|
+
if a.label not in labels: errors.append(f'Unknown label: {a.label}')
|
|
61
|
+
if a.kind not in ('box', 'polygon', 'classification'): errors.append('Unknown annotation type')
|
|
62
|
+
if a.kind == 'box' and not box_valid(a.geometry): errors.append('Box must have positive size inside the image')
|
|
63
|
+
if a.kind == 'classification' and a.geometry: errors.append('Classification cannot contain geometry')
|
|
64
|
+
if a.kind == 'polygon':
|
|
65
|
+
pts = a.geometry
|
|
66
|
+
if not isinstance(pts, list) or not 3 <= len(pts) <= 1000:
|
|
67
|
+
errors.append('Polygon needs 3–1000 vertices')
|
|
68
|
+
elif any(not isinstance(p, list) or len(p) != 2 or not all(finite_unit(x) for x in p) for p in pts):
|
|
69
|
+
errors.append('Polygon vertices must be inside the image')
|
|
70
|
+
elif len({tuple(p) for p in pts}) != len(pts) or polygon_area(pts) < 1e-10:
|
|
71
|
+
errors.append('Polygon has duplicate vertices or zero area')
|
|
72
|
+
elif polygon_self_intersects(pts): errors.append('Polygon edges intersect')
|
|
73
|
+
return errors
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def validate_all(annotations, labels, negative=False):
|
|
77
|
+
errors, seen, classes = [], set(), set()
|
|
78
|
+
for a in annotations:
|
|
79
|
+
errors.extend(validate(a, labels))
|
|
80
|
+
if a.id in seen: errors.append(f'Duplicate annotation ID: {a.id}')
|
|
81
|
+
seen.add(a.id)
|
|
82
|
+
if a.kind == 'classification':
|
|
83
|
+
if a.label in classes: errors.append(f'Duplicate class: {a.label}')
|
|
84
|
+
classes.add(a.label)
|
|
85
|
+
if negative and annotations: errors.append('No-target images cannot contain annotations')
|
|
86
|
+
return errors
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Document:
|
|
90
|
+
"""One active image. History is bounded; persisted drafts survive navigation."""
|
|
91
|
+
def __init__(self, annotations=(), negative=False):
|
|
92
|
+
self.annotations = deepcopy(list(annotations))
|
|
93
|
+
self.negative = negative
|
|
94
|
+
self.undo_stack = []
|
|
95
|
+
self.redo_stack = []
|
|
96
|
+
|
|
97
|
+
def snapshot(self):
|
|
98
|
+
return deepcopy((self.annotations, self.negative))
|
|
99
|
+
|
|
100
|
+
def change(self, annotations, negative=False):
|
|
101
|
+
next_state = deepcopy((list(annotations), negative))
|
|
102
|
+
if self.snapshot() == next_state: return False
|
|
103
|
+
self.undo_stack.append(self.snapshot())
|
|
104
|
+
self.undo_stack = self.undo_stack[-100:]
|
|
105
|
+
self.redo_stack.clear()
|
|
106
|
+
self.annotations, self.negative = next_state
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
def undo(self):
|
|
110
|
+
if not self.undo_stack: return False
|
|
111
|
+
self.redo_stack.append(self.snapshot())
|
|
112
|
+
self.annotations, self.negative = self.undo_stack.pop()
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
def redo(self):
|
|
116
|
+
if not self.redo_stack: return False
|
|
117
|
+
self.undo_stack.append(self.snapshot())
|
|
118
|
+
self.annotations, self.negative = self.redo_stack.pop()
|
|
119
|
+
return True
|