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