bbannotate 1.0.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.
- bbannotate-1.0.0.dist-info/METADATA +182 -0
- bbannotate-1.0.0.dist-info/RECORD +21 -0
- bbannotate-1.0.0.dist-info/WHEEL +4 -0
- bbannotate-1.0.0.dist-info/entry_points.txt +2 -0
- bbannotate-1.0.0.dist-info/licenses/LICENSE +21 -0
- src/__init__.py +4 -0
- src/api/__init__.py +5 -0
- src/api/routes.py +339 -0
- src/cli.py +263 -0
- src/frontend_dist/assets/index-DFfgJzXo.css +1 -0
- src/frontend_dist/assets/index-DezlWFbC.js +89 -0
- src/frontend_dist/index.html +20 -0
- src/frontend_dist/logo.png +0 -0
- src/main.py +90 -0
- src/models/__init__.py +21 -0
- src/models/annotations.py +62 -0
- src/py.typed +0 -0
- src/services/__init__.py +6 -0
- src/services/annotation_service.py +307 -0
- src/services/export_service.py +488 -0
- src/services/project_service.py +203 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
"""Service for exporting annotations to various formats."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import zipfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from src.services.annotation_service import AnnotationService
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ExportService:
|
|
11
|
+
"""Handles exporting annotations to training-ready formats."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, annotation_service: AnnotationService) -> None:
|
|
14
|
+
"""Initialize the export service.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
annotation_service: Service for accessing annotations.
|
|
18
|
+
"""
|
|
19
|
+
self.annotation_service = annotation_service
|
|
20
|
+
|
|
21
|
+
def _get_all_labels(self) -> list[str]:
|
|
22
|
+
"""Get sorted list of all unique labels in the project."""
|
|
23
|
+
labels: set[str] = set()
|
|
24
|
+
for filename in self.annotation_service.list_images():
|
|
25
|
+
annotations = self.annotation_service.get_annotations(filename)
|
|
26
|
+
for ann in annotations:
|
|
27
|
+
labels.add(ann.label)
|
|
28
|
+
return sorted(labels)
|
|
29
|
+
|
|
30
|
+
def export_yolo(
|
|
31
|
+
self,
|
|
32
|
+
output_dir: Path,
|
|
33
|
+
train_split: float = 0.7,
|
|
34
|
+
val_split: float = 0.2,
|
|
35
|
+
test_split: float = 0.1,
|
|
36
|
+
) -> Path:
|
|
37
|
+
"""Export annotations in YOLO format.
|
|
38
|
+
|
|
39
|
+
Creates the standard YOLO directory structure:
|
|
40
|
+
output_dir/
|
|
41
|
+
├── data.yaml
|
|
42
|
+
├── train/
|
|
43
|
+
│ ├── images/
|
|
44
|
+
│ └── labels/
|
|
45
|
+
├── val/
|
|
46
|
+
│ ├── images/
|
|
47
|
+
│ └── labels/
|
|
48
|
+
└── test/ (optional, if test_split > 0)
|
|
49
|
+
├── images/
|
|
50
|
+
└── labels/
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
output_dir: Directory to export to.
|
|
54
|
+
train_split: Fraction of data for training (0-1).
|
|
55
|
+
val_split: Fraction of data for validation (0-1).
|
|
56
|
+
test_split: Fraction of data for testing (0-1).
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Path to the created data.yaml file.
|
|
60
|
+
"""
|
|
61
|
+
# Normalize splits to sum to 1
|
|
62
|
+
total = train_split + val_split + test_split
|
|
63
|
+
if total > 0:
|
|
64
|
+
train_split = train_split / total
|
|
65
|
+
val_split = val_split / total
|
|
66
|
+
test_split = test_split / total
|
|
67
|
+
|
|
68
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
|
|
70
|
+
# Create directory structure
|
|
71
|
+
train_images = output_dir / "train" / "images"
|
|
72
|
+
train_labels = output_dir / "train" / "labels"
|
|
73
|
+
val_images = output_dir / "val" / "images"
|
|
74
|
+
val_labels = output_dir / "val" / "labels"
|
|
75
|
+
|
|
76
|
+
dirs_to_create = [train_images, train_labels, val_images, val_labels]
|
|
77
|
+
|
|
78
|
+
# Only create test directories if test_split > 0
|
|
79
|
+
if test_split > 0:
|
|
80
|
+
test_images = output_dir / "test" / "images"
|
|
81
|
+
test_labels = output_dir / "test" / "labels"
|
|
82
|
+
dirs_to_create.extend([test_images, test_labels])
|
|
83
|
+
else:
|
|
84
|
+
test_images = None
|
|
85
|
+
test_labels = None
|
|
86
|
+
|
|
87
|
+
for d in dirs_to_create:
|
|
88
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
|
|
90
|
+
# Get all images and split
|
|
91
|
+
images = self.annotation_service.list_images()
|
|
92
|
+
n = len(images)
|
|
93
|
+
train_end = int(n * train_split)
|
|
94
|
+
val_end = int(n * (train_split + val_split))
|
|
95
|
+
|
|
96
|
+
train_files = images[:train_end]
|
|
97
|
+
val_files = images[train_end:val_end]
|
|
98
|
+
test_files = images[val_end:] if test_split > 0 else []
|
|
99
|
+
|
|
100
|
+
# Build label mapping from project
|
|
101
|
+
labels = self._get_all_labels()
|
|
102
|
+
label_to_id = {label: idx for idx, label in enumerate(labels)}
|
|
103
|
+
|
|
104
|
+
# Export training set
|
|
105
|
+
for filename in train_files:
|
|
106
|
+
self._export_yolo_image(filename, train_images, train_labels, label_to_id)
|
|
107
|
+
|
|
108
|
+
# Export validation set
|
|
109
|
+
for filename in val_files:
|
|
110
|
+
self._export_yolo_image(filename, val_images, val_labels, label_to_id)
|
|
111
|
+
|
|
112
|
+
# Export test set
|
|
113
|
+
if test_images and test_labels:
|
|
114
|
+
for filename in test_files:
|
|
115
|
+
self._export_yolo_image(filename, test_images, test_labels, label_to_id)
|
|
116
|
+
|
|
117
|
+
# Create data.yaml
|
|
118
|
+
data_yaml = output_dir / "data.yaml"
|
|
119
|
+
yaml_content = self._create_yolo_yaml(
|
|
120
|
+
output_dir, labels, include_test=(test_split > 0)
|
|
121
|
+
)
|
|
122
|
+
data_yaml.write_text(yaml_content)
|
|
123
|
+
|
|
124
|
+
return data_yaml
|
|
125
|
+
|
|
126
|
+
def _export_yolo_image(
|
|
127
|
+
self,
|
|
128
|
+
filename: str,
|
|
129
|
+
images_dir: Path,
|
|
130
|
+
labels_dir: Path,
|
|
131
|
+
label_to_id: dict[str, int],
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Export a single image and its annotations in YOLO format."""
|
|
134
|
+
# Copy image
|
|
135
|
+
source_path = self.annotation_service.get_image_path(filename)
|
|
136
|
+
if source_path is None:
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
shutil.copy(source_path, images_dir / filename)
|
|
140
|
+
|
|
141
|
+
# Create label file
|
|
142
|
+
annotations = self.annotation_service.get_annotations(filename)
|
|
143
|
+
label_path = labels_dir / f"{Path(filename).stem}.txt"
|
|
144
|
+
|
|
145
|
+
lines = []
|
|
146
|
+
for ann in annotations:
|
|
147
|
+
class_id = label_to_id.get(ann.label, ann.class_id)
|
|
148
|
+
# YOLO format: class_id center_x center_y width height (all normalized)
|
|
149
|
+
line = (
|
|
150
|
+
f"{class_id} {ann.bbox.x:.6f} {ann.bbox.y:.6f} "
|
|
151
|
+
f"{ann.bbox.width:.6f} {ann.bbox.height:.6f}"
|
|
152
|
+
)
|
|
153
|
+
lines.append(line)
|
|
154
|
+
|
|
155
|
+
label_path.write_text("\n".join(lines))
|
|
156
|
+
|
|
157
|
+
def _create_yolo_yaml(
|
|
158
|
+
self, output_dir: Path, labels: list[str], include_test: bool = False
|
|
159
|
+
) -> str:
|
|
160
|
+
"""Create YOLO data.yaml content."""
|
|
161
|
+
lines = [
|
|
162
|
+
f"path: {output_dir.absolute()}",
|
|
163
|
+
"train: train/images",
|
|
164
|
+
"val: val/images",
|
|
165
|
+
]
|
|
166
|
+
if include_test:
|
|
167
|
+
lines.append("test: test/images")
|
|
168
|
+
lines.extend(
|
|
169
|
+
[
|
|
170
|
+
"",
|
|
171
|
+
f"nc: {len(labels)}",
|
|
172
|
+
"names:",
|
|
173
|
+
]
|
|
174
|
+
)
|
|
175
|
+
for i, label in enumerate(labels):
|
|
176
|
+
lines.append(f" {i}: {label}")
|
|
177
|
+
|
|
178
|
+
return "\n".join(lines) + "\n"
|
|
179
|
+
|
|
180
|
+
def export_yolo_zip(
|
|
181
|
+
self,
|
|
182
|
+
train_split: float = 0.7,
|
|
183
|
+
val_split: float = 0.2,
|
|
184
|
+
test_split: float = 0.1,
|
|
185
|
+
) -> Path:
|
|
186
|
+
"""Export annotations as a ZIP file for easy download.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
train_split: Fraction of data for training (0-1).
|
|
190
|
+
val_split: Fraction of data for validation (0-1).
|
|
191
|
+
test_split: Fraction of data for testing (0-1).
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Path to the created ZIP file.
|
|
195
|
+
"""
|
|
196
|
+
import tempfile
|
|
197
|
+
|
|
198
|
+
# Create temporary directory for export
|
|
199
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
200
|
+
temp_path = Path(temp_dir)
|
|
201
|
+
export_dir = temp_path / "yolo_dataset"
|
|
202
|
+
self.export_yolo(export_dir, train_split, val_split, test_split)
|
|
203
|
+
|
|
204
|
+
# Create zip file in data directory
|
|
205
|
+
zip_path = self.annotation_service.data_dir / "yolo_export.zip"
|
|
206
|
+
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
|
|
207
|
+
for file_path in export_dir.rglob("*"):
|
|
208
|
+
if file_path.is_file():
|
|
209
|
+
arcname = file_path.relative_to(export_dir)
|
|
210
|
+
zipf.write(file_path, arcname)
|
|
211
|
+
|
|
212
|
+
return zip_path
|
|
213
|
+
|
|
214
|
+
def export_coco(self, output_path: Path) -> Path:
|
|
215
|
+
"""Export annotations in COCO JSON format.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
output_path: Path for the output JSON file.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
Path to the created JSON file.
|
|
222
|
+
"""
|
|
223
|
+
import json
|
|
224
|
+
|
|
225
|
+
labels = self._get_all_labels()
|
|
226
|
+
categories = [
|
|
227
|
+
{"id": idx, "name": label, "supercategory": "product"}
|
|
228
|
+
for idx, label in enumerate(labels)
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
images_data = []
|
|
232
|
+
annotations_data = []
|
|
233
|
+
annotation_id = 1
|
|
234
|
+
|
|
235
|
+
for img_id, filename in enumerate(self.annotation_service.list_images()):
|
|
236
|
+
annotations = self.annotation_service.get_annotations(filename)
|
|
237
|
+
metadata = self.annotation_service._load_metadata(filename)
|
|
238
|
+
|
|
239
|
+
if metadata is None:
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
images_data.append(
|
|
243
|
+
{
|
|
244
|
+
"id": img_id,
|
|
245
|
+
"file_name": filename,
|
|
246
|
+
"width": metadata.image.width,
|
|
247
|
+
"height": metadata.image.height,
|
|
248
|
+
}
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
for ann in annotations:
|
|
252
|
+
# Convert from normalized center format to absolute corner format
|
|
253
|
+
width_px = ann.bbox.width * metadata.image.width
|
|
254
|
+
height_px = ann.bbox.height * metadata.image.height
|
|
255
|
+
x_min = (ann.bbox.x - ann.bbox.width / 2) * metadata.image.width
|
|
256
|
+
y_min = (ann.bbox.y - ann.bbox.height / 2) * metadata.image.height
|
|
257
|
+
|
|
258
|
+
annotations_data.append(
|
|
259
|
+
{
|
|
260
|
+
"id": annotation_id,
|
|
261
|
+
"image_id": img_id,
|
|
262
|
+
"category_id": labels.index(ann.label),
|
|
263
|
+
"bbox": [x_min, y_min, width_px, height_px],
|
|
264
|
+
"area": width_px * height_px,
|
|
265
|
+
"iscrowd": 0,
|
|
266
|
+
}
|
|
267
|
+
)
|
|
268
|
+
annotation_id += 1
|
|
269
|
+
|
|
270
|
+
coco_data = {
|
|
271
|
+
"images": images_data,
|
|
272
|
+
"annotations": annotations_data,
|
|
273
|
+
"categories": categories,
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
277
|
+
with output_path.open("w") as f:
|
|
278
|
+
json.dump(coco_data, f, indent=2)
|
|
279
|
+
|
|
280
|
+
return output_path
|
|
281
|
+
|
|
282
|
+
def export_pascal_voc(self, output_dir: Path) -> Path:
|
|
283
|
+
"""Export annotations in Pascal VOC XML format.
|
|
284
|
+
|
|
285
|
+
Creates XML files per image with the standard VOC structure.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
output_dir: Directory to export to.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
Path to the output directory.
|
|
292
|
+
"""
|
|
293
|
+
from xml.etree import ElementTree as ET
|
|
294
|
+
|
|
295
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
296
|
+
annotations_dir = output_dir / "Annotations"
|
|
297
|
+
images_dir = output_dir / "JPEGImages"
|
|
298
|
+
annotations_dir.mkdir(exist_ok=True)
|
|
299
|
+
images_dir.mkdir(exist_ok=True)
|
|
300
|
+
|
|
301
|
+
for filename in self.annotation_service.list_images():
|
|
302
|
+
annotations = self.annotation_service.get_annotations(filename)
|
|
303
|
+
metadata = self.annotation_service._load_metadata(filename)
|
|
304
|
+
source_path = self.annotation_service.get_image_path(filename)
|
|
305
|
+
|
|
306
|
+
if metadata is None or source_path is None:
|
|
307
|
+
continue
|
|
308
|
+
|
|
309
|
+
# Copy image
|
|
310
|
+
shutil.copy(source_path, images_dir / filename)
|
|
311
|
+
|
|
312
|
+
# Create XML annotation
|
|
313
|
+
root = ET.Element("annotation")
|
|
314
|
+
ET.SubElement(root, "folder").text = "JPEGImages"
|
|
315
|
+
ET.SubElement(root, "filename").text = filename
|
|
316
|
+
|
|
317
|
+
size = ET.SubElement(root, "size")
|
|
318
|
+
ET.SubElement(size, "width").text = str(metadata.image.width)
|
|
319
|
+
ET.SubElement(size, "height").text = str(metadata.image.height)
|
|
320
|
+
ET.SubElement(size, "depth").text = "3"
|
|
321
|
+
|
|
322
|
+
ET.SubElement(root, "segmented").text = "0"
|
|
323
|
+
|
|
324
|
+
for ann in annotations:
|
|
325
|
+
obj = ET.SubElement(root, "object")
|
|
326
|
+
ET.SubElement(obj, "name").text = ann.label
|
|
327
|
+
ET.SubElement(obj, "pose").text = "Unspecified"
|
|
328
|
+
ET.SubElement(obj, "truncated").text = "0"
|
|
329
|
+
ET.SubElement(obj, "difficult").text = "0"
|
|
330
|
+
|
|
331
|
+
# Convert from normalized center format to absolute corner format
|
|
332
|
+
x_min = int((ann.bbox.x - ann.bbox.width / 2) * metadata.image.width)
|
|
333
|
+
y_min = int((ann.bbox.y - ann.bbox.height / 2) * metadata.image.height)
|
|
334
|
+
x_max = int((ann.bbox.x + ann.bbox.width / 2) * metadata.image.width)
|
|
335
|
+
y_max = int((ann.bbox.y + ann.bbox.height / 2) * metadata.image.height)
|
|
336
|
+
|
|
337
|
+
bndbox = ET.SubElement(obj, "bndbox")
|
|
338
|
+
ET.SubElement(bndbox, "xmin").text = str(max(0, x_min))
|
|
339
|
+
ET.SubElement(bndbox, "ymin").text = str(max(0, y_min))
|
|
340
|
+
ET.SubElement(bndbox, "xmax").text = str(
|
|
341
|
+
min(metadata.image.width, x_max)
|
|
342
|
+
)
|
|
343
|
+
ET.SubElement(bndbox, "ymax").text = str(
|
|
344
|
+
min(metadata.image.height, y_max)
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# Write XML file
|
|
348
|
+
tree = ET.ElementTree(root)
|
|
349
|
+
xml_path = annotations_dir / f"{Path(filename).stem}.xml"
|
|
350
|
+
tree.write(xml_path, encoding="unicode", xml_declaration=True)
|
|
351
|
+
|
|
352
|
+
return output_dir
|
|
353
|
+
|
|
354
|
+
def export_pascal_voc_zip(self) -> Path:
|
|
355
|
+
"""Export Pascal VOC annotations as a ZIP file.
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
Path to the created ZIP file.
|
|
359
|
+
"""
|
|
360
|
+
import tempfile
|
|
361
|
+
|
|
362
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
363
|
+
temp_path = Path(temp_dir)
|
|
364
|
+
export_dir = temp_path / "pascal_voc_dataset"
|
|
365
|
+
self.export_pascal_voc(export_dir)
|
|
366
|
+
|
|
367
|
+
zip_path = self.annotation_service.data_dir / "pascal_voc_export.zip"
|
|
368
|
+
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
|
|
369
|
+
for file_path in export_dir.rglob("*"):
|
|
370
|
+
if file_path.is_file():
|
|
371
|
+
arcname = file_path.relative_to(export_dir)
|
|
372
|
+
zipf.write(file_path, arcname)
|
|
373
|
+
|
|
374
|
+
return zip_path
|
|
375
|
+
|
|
376
|
+
def export_createml(self, output_path: Path) -> Path:
|
|
377
|
+
"""Export annotations in Apple CreateML JSON format.
|
|
378
|
+
|
|
379
|
+
Args:
|
|
380
|
+
output_path: Path for the output JSON file.
|
|
381
|
+
|
|
382
|
+
Returns:
|
|
383
|
+
Path to the created JSON file.
|
|
384
|
+
"""
|
|
385
|
+
import json
|
|
386
|
+
|
|
387
|
+
createml_data = []
|
|
388
|
+
|
|
389
|
+
for filename in self.annotation_service.list_images():
|
|
390
|
+
annotations = self.annotation_service.get_annotations(filename)
|
|
391
|
+
metadata = self.annotation_service._load_metadata(filename)
|
|
392
|
+
|
|
393
|
+
if metadata is None:
|
|
394
|
+
continue
|
|
395
|
+
|
|
396
|
+
image_annotations = []
|
|
397
|
+
for ann in annotations:
|
|
398
|
+
# CreateML uses center-based coordinates with absolute pixels
|
|
399
|
+
width_px = ann.bbox.width * metadata.image.width
|
|
400
|
+
height_px = ann.bbox.height * metadata.image.height
|
|
401
|
+
center_x = ann.bbox.x * metadata.image.width
|
|
402
|
+
center_y = ann.bbox.y * metadata.image.height
|
|
403
|
+
|
|
404
|
+
image_annotations.append(
|
|
405
|
+
{
|
|
406
|
+
"label": ann.label,
|
|
407
|
+
"coordinates": {
|
|
408
|
+
"x": round(center_x, 2),
|
|
409
|
+
"y": round(center_y, 2),
|
|
410
|
+
"width": round(width_px, 2),
|
|
411
|
+
"height": round(height_px, 2),
|
|
412
|
+
},
|
|
413
|
+
}
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
createml_data.append({"image": filename, "annotations": image_annotations})
|
|
417
|
+
|
|
418
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
419
|
+
with output_path.open("w") as f:
|
|
420
|
+
json.dump(createml_data, f, indent=2)
|
|
421
|
+
|
|
422
|
+
return output_path
|
|
423
|
+
|
|
424
|
+
def export_csv(self, output_path: Path) -> Path:
|
|
425
|
+
"""Export annotations in CSV format.
|
|
426
|
+
|
|
427
|
+
Format: image_filename,label,x_min,y_min,x_max,y_max,width,height
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
output_path: Path for the output CSV file.
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
Path to the created CSV file.
|
|
434
|
+
"""
|
|
435
|
+
import csv
|
|
436
|
+
|
|
437
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
438
|
+
|
|
439
|
+
with output_path.open("w", newline="") as f:
|
|
440
|
+
writer = csv.writer(f)
|
|
441
|
+
writer.writerow(
|
|
442
|
+
[
|
|
443
|
+
"filename",
|
|
444
|
+
"label",
|
|
445
|
+
"x_min",
|
|
446
|
+
"y_min",
|
|
447
|
+
"x_max",
|
|
448
|
+
"y_max",
|
|
449
|
+
"image_width",
|
|
450
|
+
"image_height",
|
|
451
|
+
]
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
for filename in self.annotation_service.list_images():
|
|
455
|
+
annotations = self.annotation_service.get_annotations(filename)
|
|
456
|
+
metadata = self.annotation_service._load_metadata(filename)
|
|
457
|
+
|
|
458
|
+
if metadata is None:
|
|
459
|
+
continue
|
|
460
|
+
|
|
461
|
+
for ann in annotations:
|
|
462
|
+
x_min = int(
|
|
463
|
+
(ann.bbox.x - ann.bbox.width / 2) * metadata.image.width
|
|
464
|
+
)
|
|
465
|
+
y_min = int(
|
|
466
|
+
(ann.bbox.y - ann.bbox.height / 2) * metadata.image.height
|
|
467
|
+
)
|
|
468
|
+
x_max = int(
|
|
469
|
+
(ann.bbox.x + ann.bbox.width / 2) * metadata.image.width
|
|
470
|
+
)
|
|
471
|
+
y_max = int(
|
|
472
|
+
(ann.bbox.y + ann.bbox.height / 2) * metadata.image.height
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
writer.writerow(
|
|
476
|
+
[
|
|
477
|
+
filename,
|
|
478
|
+
ann.label,
|
|
479
|
+
max(0, x_min),
|
|
480
|
+
max(0, y_min),
|
|
481
|
+
min(metadata.image.width, x_max),
|
|
482
|
+
min(metadata.image.height, y_max),
|
|
483
|
+
metadata.image.width,
|
|
484
|
+
metadata.image.height,
|
|
485
|
+
]
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
return output_path
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Service for managing annotation projects."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Project(BaseModel):
|
|
12
|
+
"""Represents an annotation project."""
|
|
13
|
+
|
|
14
|
+
id: str = Field(..., description="Unique project identifier (directory name)")
|
|
15
|
+
name: str = Field(..., description="Human-readable project name")
|
|
16
|
+
created_at: str = Field(..., description="ISO format creation timestamp")
|
|
17
|
+
last_opened: str = Field(..., description="ISO format last opened timestamp")
|
|
18
|
+
image_count: int = Field(default=0, description="Number of images in project")
|
|
19
|
+
annotation_count: int = Field(default=0, description="Total number of annotations")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ProjectCreate(BaseModel):
|
|
23
|
+
"""Request model for creating a project."""
|
|
24
|
+
|
|
25
|
+
name: str = Field(..., min_length=1, max_length=100, description="Project name")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ProjectService:
|
|
29
|
+
"""Handles project storage and management."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, base_dir: Path) -> None:
|
|
32
|
+
"""Initialize the project service.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
base_dir: Base directory for storing all projects.
|
|
36
|
+
"""
|
|
37
|
+
self.base_dir = base_dir
|
|
38
|
+
self._ensure_directories()
|
|
39
|
+
|
|
40
|
+
def _ensure_directories(self) -> None:
|
|
41
|
+
"""Create base projects directory if it doesn't exist."""
|
|
42
|
+
self.base_dir.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
|
|
44
|
+
def _get_project_dir(self, project_id: str) -> Path:
|
|
45
|
+
"""Get the directory path for a project."""
|
|
46
|
+
return self.base_dir / project_id
|
|
47
|
+
|
|
48
|
+
def _get_project_meta_path(self, project_id: str) -> Path:
|
|
49
|
+
"""Get the path to the project metadata file."""
|
|
50
|
+
return self._get_project_dir(project_id) / "project.json"
|
|
51
|
+
|
|
52
|
+
def _generate_project_id(self, name: str) -> str:
|
|
53
|
+
"""Generate a unique project ID from name and timestamp."""
|
|
54
|
+
# Sanitize name for use in directory
|
|
55
|
+
sanitized = "".join(
|
|
56
|
+
c if c.isalnum() or c in "-_" else "_" for c in name.lower()
|
|
57
|
+
)
|
|
58
|
+
sanitized = sanitized[:50] # Limit length
|
|
59
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
60
|
+
return f"{sanitized}_{timestamp}"
|
|
61
|
+
|
|
62
|
+
def _load_project_meta(self, project_id: str) -> Project | None:
|
|
63
|
+
"""Load project metadata from JSON file."""
|
|
64
|
+
meta_path = self._get_project_meta_path(project_id)
|
|
65
|
+
if not meta_path.exists():
|
|
66
|
+
return None
|
|
67
|
+
with meta_path.open("r") as f:
|
|
68
|
+
data = json.load(f)
|
|
69
|
+
return Project.model_validate(data)
|
|
70
|
+
|
|
71
|
+
def _save_project_meta(self, project: Project) -> None:
|
|
72
|
+
"""Save project metadata to JSON file."""
|
|
73
|
+
meta_path = self._get_project_meta_path(project.id)
|
|
74
|
+
with meta_path.open("w") as f:
|
|
75
|
+
json.dump(project.model_dump(), f, indent=2)
|
|
76
|
+
|
|
77
|
+
def _count_project_stats(self, project_id: str) -> tuple[int, int]:
|
|
78
|
+
"""Count images and annotations in a project.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Tuple of (image_count, annotation_count).
|
|
82
|
+
"""
|
|
83
|
+
project_dir = self._get_project_dir(project_id)
|
|
84
|
+
images_dir = project_dir / "images"
|
|
85
|
+
annotations_dir = project_dir / "annotations"
|
|
86
|
+
|
|
87
|
+
image_count = 0
|
|
88
|
+
annotation_count = 0
|
|
89
|
+
|
|
90
|
+
if images_dir.exists():
|
|
91
|
+
extensions = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
|
|
92
|
+
for img_path in images_dir.iterdir():
|
|
93
|
+
if img_path.suffix.lower() in extensions:
|
|
94
|
+
image_count += 1
|
|
95
|
+
|
|
96
|
+
if annotations_dir.exists():
|
|
97
|
+
for ann_path in annotations_dir.glob("*.json"):
|
|
98
|
+
with ann_path.open("r") as f:
|
|
99
|
+
data = json.load(f)
|
|
100
|
+
annotations = data.get("annotations", [])
|
|
101
|
+
annotation_count += len(annotations)
|
|
102
|
+
|
|
103
|
+
return image_count, annotation_count
|
|
104
|
+
|
|
105
|
+
def list_projects(self) -> list[Project]:
|
|
106
|
+
"""List all projects, sorted by last opened (most recent first)."""
|
|
107
|
+
projects = []
|
|
108
|
+
|
|
109
|
+
for project_dir in self.base_dir.iterdir():
|
|
110
|
+
if not project_dir.is_dir():
|
|
111
|
+
continue
|
|
112
|
+
project = self._load_project_meta(project_dir.name)
|
|
113
|
+
if project:
|
|
114
|
+
# Update counts
|
|
115
|
+
image_count, annotation_count = self._count_project_stats(project.id)
|
|
116
|
+
project.image_count = image_count
|
|
117
|
+
project.annotation_count = annotation_count
|
|
118
|
+
projects.append(project)
|
|
119
|
+
|
|
120
|
+
# Sort by last_opened, most recent first
|
|
121
|
+
projects.sort(key=lambda p: p.last_opened, reverse=True)
|
|
122
|
+
return projects
|
|
123
|
+
|
|
124
|
+
def create_project(self, create: ProjectCreate) -> Project:
|
|
125
|
+
"""Create a new project.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
create: Project creation request.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
The created project.
|
|
132
|
+
"""
|
|
133
|
+
project_id = self._generate_project_id(create.name)
|
|
134
|
+
project_dir = self._get_project_dir(project_id)
|
|
135
|
+
|
|
136
|
+
# Create project directories
|
|
137
|
+
project_dir.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
(project_dir / "images").mkdir(exist_ok=True)
|
|
139
|
+
(project_dir / "annotations").mkdir(exist_ok=True)
|
|
140
|
+
|
|
141
|
+
now = datetime.now().isoformat()
|
|
142
|
+
project = Project(
|
|
143
|
+
id=project_id,
|
|
144
|
+
name=create.name,
|
|
145
|
+
created_at=now,
|
|
146
|
+
last_opened=now,
|
|
147
|
+
image_count=0,
|
|
148
|
+
annotation_count=0,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
self._save_project_meta(project)
|
|
152
|
+
return project
|
|
153
|
+
|
|
154
|
+
def get_project(self, project_id: str) -> Project | None:
|
|
155
|
+
"""Get a project by ID."""
|
|
156
|
+
project = self._load_project_meta(project_id)
|
|
157
|
+
if project:
|
|
158
|
+
image_count, annotation_count = self._count_project_stats(project_id)
|
|
159
|
+
project.image_count = image_count
|
|
160
|
+
project.annotation_count = annotation_count
|
|
161
|
+
return project
|
|
162
|
+
|
|
163
|
+
def open_project(self, project_id: str) -> Project | None:
|
|
164
|
+
"""Open a project and update its last_opened timestamp.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
The project if found, None otherwise.
|
|
168
|
+
"""
|
|
169
|
+
project = self._load_project_meta(project_id)
|
|
170
|
+
if not project:
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
# Update last_opened
|
|
174
|
+
project.last_opened = datetime.now().isoformat()
|
|
175
|
+
image_count, annotation_count = self._count_project_stats(project_id)
|
|
176
|
+
project.image_count = image_count
|
|
177
|
+
project.annotation_count = annotation_count
|
|
178
|
+
|
|
179
|
+
self._save_project_meta(project)
|
|
180
|
+
return project
|
|
181
|
+
|
|
182
|
+
def delete_project(self, project_id: str) -> bool:
|
|
183
|
+
"""Delete a project and all its data.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
True if deleted, False if not found.
|
|
187
|
+
"""
|
|
188
|
+
project_dir = self._get_project_dir(project_id)
|
|
189
|
+
if not project_dir.exists():
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
shutil.rmtree(project_dir)
|
|
193
|
+
return True
|
|
194
|
+
|
|
195
|
+
def get_project_data_dir(self, project_id: str) -> Path | None:
|
|
196
|
+
"""Get the data directory for a project.
|
|
197
|
+
|
|
198
|
+
This is the directory that should be passed to AnnotationService.
|
|
199
|
+
"""
|
|
200
|
+
project_dir = self._get_project_dir(project_id)
|
|
201
|
+
if not project_dir.exists():
|
|
202
|
+
return None
|
|
203
|
+
return project_dir
|