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,20 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<script>
|
|
5
|
+
if (localStorage.getItem('darkMode') === 'true' ||
|
|
6
|
+
(!localStorage.getItem('darkMode') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
7
|
+
document.documentElement.classList.add('dark');
|
|
8
|
+
}
|
|
9
|
+
</script>
|
|
10
|
+
<meta charset="UTF-8" />
|
|
11
|
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
12
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
13
|
+
<title>Bounding Box Annotation Tool</title>
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-DezlWFbC.js"></script>
|
|
15
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DFfgJzXo.css">
|
|
16
|
+
</head>
|
|
17
|
+
<body>
|
|
18
|
+
<div id="root"></div>
|
|
19
|
+
</body>
|
|
20
|
+
</html>
|
|
Binary file
|
src/main.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""FastAPI application entry point for the annotation tool."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
8
|
+
from fastapi.staticfiles import StaticFiles
|
|
9
|
+
|
|
10
|
+
from src import __version__
|
|
11
|
+
from src.api.routes import get_data_dir, router
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _find_frontend_dist() -> Path | None:
|
|
15
|
+
"""Find the frontend dist directory.
|
|
16
|
+
|
|
17
|
+
Checks in order:
|
|
18
|
+
1. Bundled with package (src/frontend_dist) - for pip install
|
|
19
|
+
2. Relative to package (frontend/dist) - for development
|
|
20
|
+
"""
|
|
21
|
+
# Check bundled location (pip install includes frontend_dist in src/)
|
|
22
|
+
package_dir = Path(__file__).parent
|
|
23
|
+
bundled_path = package_dir / "frontend_dist"
|
|
24
|
+
if bundled_path.exists() and (bundled_path / "index.html").exists():
|
|
25
|
+
return bundled_path
|
|
26
|
+
|
|
27
|
+
# Check relative to package root (development mode with frontend/dist)
|
|
28
|
+
dev_path = package_dir.parent / "frontend" / "dist"
|
|
29
|
+
if dev_path.exists() and (dev_path / "index.html").exists():
|
|
30
|
+
return dev_path
|
|
31
|
+
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Create FastAPI app
|
|
36
|
+
app = FastAPI(
|
|
37
|
+
title="Bounding Box Annotation Tool",
|
|
38
|
+
description="A lightweight annotation tool for grocery flyer product detection",
|
|
39
|
+
version=__version__,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Configure CORS for frontend development
|
|
43
|
+
app.add_middleware(
|
|
44
|
+
CORSMiddleware,
|
|
45
|
+
allow_origins=[
|
|
46
|
+
"http://localhost:5173", # Vite dev server
|
|
47
|
+
"http://localhost:3000",
|
|
48
|
+
"http://127.0.0.1:5173",
|
|
49
|
+
"http://127.0.0.1:3000",
|
|
50
|
+
],
|
|
51
|
+
allow_credentials=True,
|
|
52
|
+
allow_methods=["*"],
|
|
53
|
+
allow_headers=["*"],
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Include API routes
|
|
57
|
+
app.include_router(router, prefix="/api")
|
|
58
|
+
|
|
59
|
+
# Ensure data directory exists
|
|
60
|
+
get_data_dir().mkdir(parents=True, exist_ok=True)
|
|
61
|
+
|
|
62
|
+
# Serve frontend static files in production
|
|
63
|
+
FRONTEND_DIST = _find_frontend_dist()
|
|
64
|
+
if FRONTEND_DIST:
|
|
65
|
+
app.mount("/", StaticFiles(directory=FRONTEND_DIST, html=True), name="frontend")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.get("/health")
|
|
69
|
+
def health_check() -> dict[str, str]:
|
|
70
|
+
"""Health check endpoint."""
|
|
71
|
+
return {"status": "healthy"}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main() -> None:
|
|
75
|
+
"""Run the development server."""
|
|
76
|
+
import uvicorn
|
|
77
|
+
|
|
78
|
+
host = os.getenv("HOST", "127.0.0.1")
|
|
79
|
+
port = int(os.getenv("PORT", "8000"))
|
|
80
|
+
|
|
81
|
+
uvicorn.run(
|
|
82
|
+
"src.main:app",
|
|
83
|
+
host=host,
|
|
84
|
+
port=port,
|
|
85
|
+
reload=True,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
main()
|
src/models/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Data models for the annotation tool."""
|
|
2
|
+
|
|
3
|
+
from src.models.annotations import (
|
|
4
|
+
Annotation,
|
|
5
|
+
AnnotationCreate,
|
|
6
|
+
AnnotationUpdate,
|
|
7
|
+
BoundingBox,
|
|
8
|
+
ImageInfo,
|
|
9
|
+
ImageMetadata,
|
|
10
|
+
ProjectInfo,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Annotation",
|
|
15
|
+
"AnnotationCreate",
|
|
16
|
+
"AnnotationUpdate",
|
|
17
|
+
"BoundingBox",
|
|
18
|
+
"ImageInfo",
|
|
19
|
+
"ImageMetadata",
|
|
20
|
+
"ProjectInfo",
|
|
21
|
+
]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Pydantic models for annotation data."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BoundingBox(BaseModel):
|
|
7
|
+
"""Represents a bounding box with normalized coordinates (0-1)."""
|
|
8
|
+
|
|
9
|
+
x: float = Field(..., ge=0, le=1, description="Center X coordinate (normalized)")
|
|
10
|
+
y: float = Field(..., ge=0, le=1, description="Center Y coordinate (normalized)")
|
|
11
|
+
width: float = Field(..., ge=0, le=1, description="Box width (normalized)")
|
|
12
|
+
height: float = Field(..., ge=0, le=1, description="Box height (normalized)")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Annotation(BaseModel):
|
|
16
|
+
"""A single annotation with bounding box and label."""
|
|
17
|
+
|
|
18
|
+
id: str = Field(..., description="Unique identifier for the annotation")
|
|
19
|
+
label: str = Field(..., description="Class label for the annotation")
|
|
20
|
+
class_id: int = Field(..., ge=0, description="Numeric class ID for YOLO export")
|
|
21
|
+
bbox: BoundingBox = Field(..., description="Bounding box coordinates")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AnnotationCreate(BaseModel):
|
|
25
|
+
"""Request model for creating an annotation."""
|
|
26
|
+
|
|
27
|
+
label: str = Field(..., min_length=1, description="Class label")
|
|
28
|
+
class_id: int = Field(..., ge=0, description="Numeric class ID")
|
|
29
|
+
bbox: BoundingBox
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AnnotationUpdate(BaseModel):
|
|
33
|
+
"""Request model for updating an annotation."""
|
|
34
|
+
|
|
35
|
+
label: str | None = Field(None, min_length=1)
|
|
36
|
+
class_id: int | None = Field(None, ge=0)
|
|
37
|
+
bbox: BoundingBox | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ImageInfo(BaseModel):
|
|
41
|
+
"""Basic image information."""
|
|
42
|
+
|
|
43
|
+
filename: str
|
|
44
|
+
width: int
|
|
45
|
+
height: int
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ImageMetadata(BaseModel):
|
|
49
|
+
"""Complete metadata for an annotated image."""
|
|
50
|
+
|
|
51
|
+
image: ImageInfo
|
|
52
|
+
annotations: list[Annotation] = Field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ProjectInfo(BaseModel):
|
|
56
|
+
"""Project-level information."""
|
|
57
|
+
|
|
58
|
+
name: str = Field(default="grocery-flyer-annotations")
|
|
59
|
+
labels: list[str] = Field(default_factory=list)
|
|
60
|
+
image_count: int = 0
|
|
61
|
+
annotation_count: int = 0
|
|
62
|
+
annotated_image_count: int = 0
|
src/py.typed
ADDED
|
File without changes
|
src/services/__init__.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Service for managing annotations and images."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import uuid
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from PIL import Image
|
|
9
|
+
|
|
10
|
+
from src.models.annotations import (
|
|
11
|
+
Annotation,
|
|
12
|
+
AnnotationCreate,
|
|
13
|
+
AnnotationUpdate,
|
|
14
|
+
ImageInfo,
|
|
15
|
+
ImageMetadata,
|
|
16
|
+
ProjectInfo,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AnnotationService:
|
|
21
|
+
"""Handles image and annotation storage operations."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, data_dir: Path) -> None:
|
|
24
|
+
"""Initialize the annotation service.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
data_dir: Root directory for storing images and annotations.
|
|
28
|
+
"""
|
|
29
|
+
self.data_dir = data_dir
|
|
30
|
+
self.images_dir = data_dir / "images"
|
|
31
|
+
self.annotations_dir = data_dir / "annotations"
|
|
32
|
+
self._ensure_directories()
|
|
33
|
+
|
|
34
|
+
def _ensure_directories(self) -> None:
|
|
35
|
+
"""Create necessary directories if they don't exist."""
|
|
36
|
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
self.images_dir.mkdir(exist_ok=True)
|
|
38
|
+
self.annotations_dir.mkdir(exist_ok=True)
|
|
39
|
+
|
|
40
|
+
def _get_annotation_path(self, image_filename: str) -> Path:
|
|
41
|
+
"""Get the path to the annotation JSON file for an image."""
|
|
42
|
+
stem = Path(image_filename).stem
|
|
43
|
+
return self.annotations_dir / f"{stem}.json"
|
|
44
|
+
|
|
45
|
+
def _load_metadata(self, image_filename: str) -> ImageMetadata | None:
|
|
46
|
+
"""Load metadata for an image from JSON file."""
|
|
47
|
+
annotation_path = self._get_annotation_path(image_filename)
|
|
48
|
+
if not annotation_path.exists():
|
|
49
|
+
return None
|
|
50
|
+
with annotation_path.open("r") as f:
|
|
51
|
+
data = json.load(f)
|
|
52
|
+
return ImageMetadata.model_validate(data)
|
|
53
|
+
|
|
54
|
+
def _save_metadata(self, metadata: ImageMetadata) -> None:
|
|
55
|
+
"""Save metadata to JSON file."""
|
|
56
|
+
annotation_path = self._get_annotation_path(metadata.image.filename)
|
|
57
|
+
with annotation_path.open("w") as f:
|
|
58
|
+
json.dump(metadata.model_dump(), f, indent=2)
|
|
59
|
+
|
|
60
|
+
def get_project_info(self) -> ProjectInfo:
|
|
61
|
+
"""Get project-level statistics and label information."""
|
|
62
|
+
labels: set[str] = set()
|
|
63
|
+
total_annotations = 0
|
|
64
|
+
image_count = 0
|
|
65
|
+
annotated_image_count = 0
|
|
66
|
+
|
|
67
|
+
for annotation_file in self.annotations_dir.glob("*.json"):
|
|
68
|
+
with annotation_file.open("r") as f:
|
|
69
|
+
data = json.load(f)
|
|
70
|
+
metadata = ImageMetadata.model_validate(data)
|
|
71
|
+
image_count += 1
|
|
72
|
+
annotation_count = len(metadata.annotations)
|
|
73
|
+
total_annotations += annotation_count
|
|
74
|
+
if annotation_count > 0:
|
|
75
|
+
annotated_image_count += 1
|
|
76
|
+
for ann in metadata.annotations:
|
|
77
|
+
labels.add(ann.label)
|
|
78
|
+
|
|
79
|
+
return ProjectInfo(
|
|
80
|
+
labels=sorted(labels),
|
|
81
|
+
image_count=image_count,
|
|
82
|
+
annotation_count=total_annotations,
|
|
83
|
+
annotated_image_count=annotated_image_count,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def list_images(self) -> list[str]:
|
|
87
|
+
"""List all image filenames in the project."""
|
|
88
|
+
extensions = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
|
|
89
|
+
images = []
|
|
90
|
+
for img_path in self.images_dir.iterdir():
|
|
91
|
+
if img_path.suffix.lower() in extensions:
|
|
92
|
+
images.append(img_path.name)
|
|
93
|
+
return sorted(images)
|
|
94
|
+
|
|
95
|
+
def upload_image(self, filename: str, content: bytes) -> ImageInfo:
|
|
96
|
+
"""Upload and store an image.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
filename: Original filename.
|
|
100
|
+
content: Image file bytes.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
ImageInfo with dimensions.
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
ValueError: If the file is not a valid image.
|
|
107
|
+
"""
|
|
108
|
+
# Validate and get image dimensions
|
|
109
|
+
from io import BytesIO
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
img = Image.open(BytesIO(content))
|
|
113
|
+
img.verify() # Verify it's a valid image
|
|
114
|
+
img = Image.open(BytesIO(content)) # Re-open after verify
|
|
115
|
+
width, height = img.size
|
|
116
|
+
except Exception as err:
|
|
117
|
+
raise ValueError(f"Invalid image file: {err}") from err
|
|
118
|
+
|
|
119
|
+
# Save image
|
|
120
|
+
safe_filename = Path(filename).name # Prevent path traversal
|
|
121
|
+
image_path = self.images_dir / safe_filename
|
|
122
|
+
|
|
123
|
+
# Handle duplicate filenames
|
|
124
|
+
if image_path.exists():
|
|
125
|
+
stem = image_path.stem
|
|
126
|
+
suffix = image_path.suffix
|
|
127
|
+
counter = 1
|
|
128
|
+
while image_path.exists():
|
|
129
|
+
safe_filename = f"{stem}_{counter}{suffix}"
|
|
130
|
+
image_path = self.images_dir / safe_filename
|
|
131
|
+
counter += 1
|
|
132
|
+
|
|
133
|
+
image_path.write_bytes(content)
|
|
134
|
+
|
|
135
|
+
# Create initial metadata
|
|
136
|
+
image_info = ImageInfo(filename=safe_filename, width=width, height=height)
|
|
137
|
+
metadata = ImageMetadata(image=image_info, annotations=[])
|
|
138
|
+
self._save_metadata(metadata)
|
|
139
|
+
|
|
140
|
+
return image_info
|
|
141
|
+
|
|
142
|
+
def get_image_path(self, filename: str) -> Path | None:
|
|
143
|
+
"""Get the full path to an image file."""
|
|
144
|
+
path = self.images_dir / filename
|
|
145
|
+
if path.exists() and path.is_file():
|
|
146
|
+
return path
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
def delete_image(self, filename: str) -> bool:
|
|
150
|
+
"""Delete an image and its annotations."""
|
|
151
|
+
image_path = self.images_dir / filename
|
|
152
|
+
annotation_path = self._get_annotation_path(filename)
|
|
153
|
+
|
|
154
|
+
deleted = False
|
|
155
|
+
if image_path.exists():
|
|
156
|
+
image_path.unlink()
|
|
157
|
+
deleted = True
|
|
158
|
+
if annotation_path.exists():
|
|
159
|
+
annotation_path.unlink()
|
|
160
|
+
deleted = True
|
|
161
|
+
|
|
162
|
+
return deleted
|
|
163
|
+
|
|
164
|
+
def get_annotations(self, image_filename: str) -> list[Annotation]:
|
|
165
|
+
"""Get all annotations for an image."""
|
|
166
|
+
metadata = self._load_metadata(image_filename)
|
|
167
|
+
if metadata is None:
|
|
168
|
+
return []
|
|
169
|
+
return metadata.annotations
|
|
170
|
+
|
|
171
|
+
def add_annotation(
|
|
172
|
+
self, image_filename: str, annotation: AnnotationCreate
|
|
173
|
+
) -> Annotation:
|
|
174
|
+
"""Add a new annotation to an image.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
image_filename: The image to annotate.
|
|
178
|
+
annotation: The annotation data.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
The created annotation with generated ID.
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
FileNotFoundError: If the image doesn't exist.
|
|
185
|
+
"""
|
|
186
|
+
metadata = self._load_metadata(image_filename)
|
|
187
|
+
if metadata is None:
|
|
188
|
+
image_path = self.images_dir / image_filename
|
|
189
|
+
if not image_path.exists():
|
|
190
|
+
raise FileNotFoundError(f"Image not found: {image_filename}")
|
|
191
|
+
# Create metadata if missing
|
|
192
|
+
img = Image.open(image_path)
|
|
193
|
+
image_info = ImageInfo(
|
|
194
|
+
filename=image_filename, width=img.width, height=img.height
|
|
195
|
+
)
|
|
196
|
+
metadata = ImageMetadata(image=image_info, annotations=[])
|
|
197
|
+
|
|
198
|
+
new_annotation = Annotation(
|
|
199
|
+
id=str(uuid.uuid4()),
|
|
200
|
+
label=annotation.label,
|
|
201
|
+
class_id=annotation.class_id,
|
|
202
|
+
bbox=annotation.bbox,
|
|
203
|
+
)
|
|
204
|
+
metadata.annotations.append(new_annotation)
|
|
205
|
+
self._save_metadata(metadata)
|
|
206
|
+
|
|
207
|
+
return new_annotation
|
|
208
|
+
|
|
209
|
+
def update_annotation(
|
|
210
|
+
self, image_filename: str, annotation_id: str, update: AnnotationUpdate
|
|
211
|
+
) -> Annotation | None:
|
|
212
|
+
"""Update an existing annotation."""
|
|
213
|
+
metadata = self._load_metadata(image_filename)
|
|
214
|
+
if metadata is None:
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
for i, ann in enumerate(metadata.annotations):
|
|
218
|
+
if ann.id == annotation_id:
|
|
219
|
+
updated_data = ann.model_dump()
|
|
220
|
+
update_dict = update.model_dump(exclude_none=True)
|
|
221
|
+
updated_data.update(update_dict)
|
|
222
|
+
metadata.annotations[i] = Annotation.model_validate(updated_data)
|
|
223
|
+
self._save_metadata(metadata)
|
|
224
|
+
return metadata.annotations[i]
|
|
225
|
+
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
def delete_annotation(self, image_filename: str, annotation_id: str) -> bool:
|
|
229
|
+
"""Delete an annotation from an image."""
|
|
230
|
+
metadata = self._load_metadata(image_filename)
|
|
231
|
+
if metadata is None:
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
original_count = len(metadata.annotations)
|
|
235
|
+
metadata.annotations = [
|
|
236
|
+
ann for ann in metadata.annotations if ann.id != annotation_id
|
|
237
|
+
]
|
|
238
|
+
|
|
239
|
+
if len(metadata.annotations) < original_count:
|
|
240
|
+
self._save_metadata(metadata)
|
|
241
|
+
return True
|
|
242
|
+
|
|
243
|
+
return False
|
|
244
|
+
|
|
245
|
+
def clear_annotations(self, image_filename: str) -> int:
|
|
246
|
+
"""Clear all annotations for an image."""
|
|
247
|
+
metadata = self._load_metadata(image_filename)
|
|
248
|
+
if metadata is None:
|
|
249
|
+
return 0
|
|
250
|
+
|
|
251
|
+
count = len(metadata.annotations)
|
|
252
|
+
metadata.annotations = []
|
|
253
|
+
self._save_metadata(metadata)
|
|
254
|
+
|
|
255
|
+
return count
|
|
256
|
+
|
|
257
|
+
def copy_annotations(self, source_filename: str, target_filename: str) -> int:
|
|
258
|
+
"""Copy annotations from one image to another.
|
|
259
|
+
|
|
260
|
+
Useful for batch operations on similar flyer pages.
|
|
261
|
+
"""
|
|
262
|
+
source_metadata = self._load_metadata(source_filename)
|
|
263
|
+
if source_metadata is None:
|
|
264
|
+
return 0
|
|
265
|
+
|
|
266
|
+
target_metadata = self._load_metadata(target_filename)
|
|
267
|
+
if target_metadata is None:
|
|
268
|
+
return 0
|
|
269
|
+
|
|
270
|
+
# Generate new IDs for copied annotations
|
|
271
|
+
copied_annotations = []
|
|
272
|
+
for ann in source_metadata.annotations:
|
|
273
|
+
copied = Annotation(
|
|
274
|
+
id=str(uuid.uuid4()),
|
|
275
|
+
label=ann.label,
|
|
276
|
+
class_id=ann.class_id,
|
|
277
|
+
bbox=ann.bbox,
|
|
278
|
+
)
|
|
279
|
+
copied_annotations.append(copied)
|
|
280
|
+
|
|
281
|
+
target_metadata.annotations.extend(copied_annotations)
|
|
282
|
+
self._save_metadata(target_metadata)
|
|
283
|
+
|
|
284
|
+
return len(copied_annotations)
|
|
285
|
+
|
|
286
|
+
def backup_project(self, backup_path: Path) -> Path:
|
|
287
|
+
"""Create a backup of the entire project.
|
|
288
|
+
|
|
289
|
+
Only backs up the images and annotations directories to avoid
|
|
290
|
+
infinite recursion when backup_path is inside data_dir.
|
|
291
|
+
"""
|
|
292
|
+
backup_path.mkdir(parents=True, exist_ok=True)
|
|
293
|
+
backup_dir = backup_path / f"backup_{uuid.uuid4().hex[:8]}"
|
|
294
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
295
|
+
|
|
296
|
+
# Copy only images and annotations directories
|
|
297
|
+
if self.images_dir.exists():
|
|
298
|
+
shutil.copytree(self.images_dir, backup_dir / "images")
|
|
299
|
+
else:
|
|
300
|
+
(backup_dir / "images").mkdir()
|
|
301
|
+
|
|
302
|
+
if self.annotations_dir.exists():
|
|
303
|
+
shutil.copytree(self.annotations_dir, backup_dir / "annotations")
|
|
304
|
+
else:
|
|
305
|
+
(backup_dir / "annotations").mkdir()
|
|
306
|
+
|
|
307
|
+
return backup_dir
|