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.
@@ -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
@@ -0,0 +1,6 @@
1
+ """Services for the annotation tool."""
2
+
3
+ from src.services.annotation_service import AnnotationService
4
+ from src.services.export_service import ExportService
5
+
6
+ __all__ = ["AnnotationService", "ExportService"]
@@ -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