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