superiorvision 0.30.0.dev0__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.
Files changed (96) hide show
  1. superiorvision/__init__.py +19 -0
  2. superiorvision-0.30.0.dev0.dist-info/METADATA +398 -0
  3. superiorvision-0.30.0.dev0.dist-info/RECORD +96 -0
  4. superiorvision-0.30.0.dev0.dist-info/WHEEL +5 -0
  5. superiorvision-0.30.0.dev0.dist-info/licenses/LICENSE.md +9 -0
  6. superiorvision-0.30.0.dev0.dist-info/top_level.txt +2 -0
  7. supervision/__init__.py +318 -0
  8. supervision/annotators/__init__.py +0 -0
  9. supervision/annotators/base.py +21 -0
  10. supervision/annotators/core.py +3551 -0
  11. supervision/annotators/utils.py +541 -0
  12. supervision/assets/__init__.py +4 -0
  13. supervision/assets/downloader.py +166 -0
  14. supervision/assets/list.py +84 -0
  15. supervision/classification/__init__.py +0 -0
  16. supervision/classification/core.py +216 -0
  17. supervision/config.py +13 -0
  18. supervision/dataset/__init__.py +0 -0
  19. supervision/dataset/core.py +1313 -0
  20. supervision/dataset/formats/__init__.py +0 -0
  21. supervision/dataset/formats/coco.py +708 -0
  22. supervision/dataset/formats/createml.py +331 -0
  23. supervision/dataset/formats/labelme.py +405 -0
  24. supervision/dataset/formats/pascal_voc.py +449 -0
  25. supervision/dataset/formats/yolo.py +502 -0
  26. supervision/dataset/utils.py +265 -0
  27. supervision/detection/__init__.py +0 -0
  28. supervision/detection/compact_mask.py +1927 -0
  29. supervision/detection/core.py +3864 -0
  30. supervision/detection/line_zone.py +924 -0
  31. supervision/detection/overlap_filter.py +426 -0
  32. supervision/detection/tensor_utils.py +1596 -0
  33. supervision/detection/tensor_views.py +48 -0
  34. supervision/detection/tools/__init__.py +0 -0
  35. supervision/detection/tools/csv_sink.py +242 -0
  36. supervision/detection/tools/inference_slicer.py +776 -0
  37. supervision/detection/tools/json_sink.py +215 -0
  38. supervision/detection/tools/polygon_zone.py +266 -0
  39. supervision/detection/tools/smoother.py +218 -0
  40. supervision/detection/tools/transformers.py +265 -0
  41. supervision/detection/utils/__init__.py +12 -0
  42. supervision/detection/utils/_typing.py +11 -0
  43. supervision/detection/utils/boxes.py +510 -0
  44. supervision/detection/utils/converters.py +830 -0
  45. supervision/detection/utils/internal.py +806 -0
  46. supervision/detection/utils/iou_and_nms.py +1606 -0
  47. supervision/detection/utils/masks.py +625 -0
  48. supervision/detection/utils/polygons.py +128 -0
  49. supervision/detection/utils/vlms.py +97 -0
  50. supervision/detection/vlm.py +945 -0
  51. supervision/draw/__init__.py +0 -0
  52. supervision/draw/base.py +14 -0
  53. supervision/draw/color.py +598 -0
  54. supervision/draw/utils.py +527 -0
  55. supervision/geometry/__init__.py +0 -0
  56. supervision/geometry/core.py +208 -0
  57. supervision/geometry/utils.py +54 -0
  58. supervision/key_points/__init__.py +0 -0
  59. supervision/key_points/annotators.py +997 -0
  60. supervision/key_points/core.py +1506 -0
  61. supervision/key_points/skeletons.py +2646 -0
  62. supervision/keypoint/__init__.py +13 -0
  63. supervision/keypoint/annotators.py +13 -0
  64. supervision/keypoint/core.py +8 -0
  65. supervision/metrics/__init__.py +40 -0
  66. supervision/metrics/core.py +74 -0
  67. supervision/metrics/detection.py +1632 -0
  68. supervision/metrics/f1_score.py +815 -0
  69. supervision/metrics/mean_average_precision.py +1723 -0
  70. supervision/metrics/mean_average_recall.py +734 -0
  71. supervision/metrics/precision.py +815 -0
  72. supervision/metrics/recall.py +774 -0
  73. supervision/metrics/utils/__init__.py +0 -0
  74. supervision/metrics/utils/matching.py +56 -0
  75. supervision/metrics/utils/object_size.py +256 -0
  76. supervision/metrics/utils/utils.py +9 -0
  77. supervision/py.typed +0 -0
  78. supervision/tracker/__init__.py +5 -0
  79. supervision/tracker/byte_tracker/__init__.py +0 -0
  80. supervision/tracker/byte_tracker/core.py +448 -0
  81. supervision/tracker/byte_tracker/kalman_filter.py +186 -0
  82. supervision/tracker/byte_tracker/matching.py +211 -0
  83. supervision/tracker/byte_tracker/single_object_track.py +194 -0
  84. supervision/tracker/byte_tracker/utils.py +34 -0
  85. supervision/utils/__init__.py +0 -0
  86. supervision/utils/conversion.py +223 -0
  87. supervision/utils/deprecate.py +54 -0
  88. supervision/utils/file.py +209 -0
  89. supervision/utils/image.py +988 -0
  90. supervision/utils/internal.py +209 -0
  91. supervision/utils/iterables.py +103 -0
  92. supervision/utils/logger.py +61 -0
  93. supervision/utils/notebook.py +124 -0
  94. supervision/utils/tensor.py +362 -0
  95. supervision/utils/video.py +533 -0
  96. supervision/validators/__init__.py +379 -0
@@ -0,0 +1,708 @@
1
+ from __future__ import annotations
2
+
3
+ import warnings
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Any, cast
7
+
8
+ import numpy as np
9
+ import numpy.typing as npt
10
+ from tqdm.auto import tqdm
11
+
12
+ from supervision.config import COCO_RAW_SEGMENTATION
13
+ from supervision.dataset.utils import (
14
+ approximate_mask_with_polygons,
15
+ check_no_basename_collisions,
16
+ map_detections_class_id,
17
+ )
18
+ from supervision.detection.core import Detections
19
+ from supervision.detection.utils.converters import (
20
+ mask_to_rle,
21
+ polygon_to_mask,
22
+ rle_to_mask,
23
+ )
24
+ from supervision.detection.utils.masks import contains_holes, contains_multiple_segments
25
+ from supervision.utils.file import read_json_file, save_json_file
26
+
27
+ if TYPE_CHECKING:
28
+ from supervision.dataset.core import DetectionDataset
29
+
30
+ CocoDict = dict[str, Any]
31
+
32
+
33
+ def coco_categories_to_classes(coco_categories: list[CocoDict]) -> list[str]:
34
+ return [
35
+ category["name"]
36
+ for category in sorted(coco_categories, key=lambda category: category["id"])
37
+ ]
38
+
39
+
40
+ def build_coco_class_index_mapping(
41
+ coco_categories: list[CocoDict], target_classes: list[str]
42
+ ) -> dict[int, int]:
43
+ source_class_to_index = {
44
+ category["name"]: category["id"] for category in coco_categories
45
+ }
46
+ return {
47
+ source_class_to_index[target_class_name]: target_class_index
48
+ for target_class_index, target_class_name in enumerate(target_classes)
49
+ }
50
+
51
+
52
+ def classes_to_coco_categories(classes: list[str]) -> list[CocoDict]:
53
+ """Convert a list of class names to COCO ``categories`` entries.
54
+
55
+ Category ids are emitted 1-indexed to comply with the COCO specification
56
+ and tools such as CVAT, which require ``category_id`` values to start at
57
+ ``1``. The id assigned to the class at position ``class_index`` is
58
+ ``class_index + 1``, keeping it consistent with the ``category_id`` written
59
+ by [`detections_to_coco_annotations`](#detections_to_coco_annotations).
60
+
61
+ Args:
62
+ classes: Class names ordered by their internal (0-indexed) class id.
63
+
64
+ Returns:
65
+ A list of COCO category dictionaries with 1-indexed ``id`` values.
66
+
67
+ Examples:
68
+ ```python
69
+ from supervision.dataset.formats.coco import classes_to_coco_categories
70
+
71
+ classes_to_coco_categories(classes=["cat", "dog"])
72
+ # [
73
+ # {"id": 1, "name": "cat", "supercategory": "common-objects"},
74
+ # {"id": 2, "name": "dog", "supercategory": "common-objects"},
75
+ # ]
76
+ ```
77
+ """
78
+ return [
79
+ {
80
+ "id": class_index + 1,
81
+ "name": class_name,
82
+ "supercategory": "common-objects",
83
+ }
84
+ for class_index, class_name in enumerate(classes)
85
+ ]
86
+
87
+
88
+ def group_coco_annotations_by_image_id(
89
+ coco_annotations: list[CocoDict],
90
+ ) -> dict[int, list[CocoDict]]:
91
+ annotations: dict[int, list[CocoDict]] = {}
92
+ for annotation in coco_annotations:
93
+ image_id = annotation["image_id"]
94
+ if image_id not in annotations:
95
+ annotations[image_id] = []
96
+ annotations[image_id].append(annotation)
97
+ return annotations
98
+
99
+
100
+ def coco_annotations_to_masks(
101
+ image_annotations: list[CocoDict], resolution_wh: tuple[int, int]
102
+ ) -> npt.NDArray[np.bool_]:
103
+ height, width = resolution_wh[1], resolution_wh[0]
104
+ empty_mask: npt.NDArray[np.bool_] = np.zeros((height, width), dtype=bool)
105
+ masks = []
106
+
107
+ for image_annotation in image_annotations:
108
+ segmentation = image_annotation.get("segmentation")
109
+ if not segmentation:
110
+ # `force_masks=True` may request masks even for bbox-only annotations.
111
+ # Keep detection count aligned by emitting an empty mask for that object.
112
+ masks.append(empty_mask.copy())
113
+ continue
114
+
115
+ if isinstance(segmentation, dict):
116
+ if "counts" not in segmentation:
117
+ warnings.warn(
118
+ "Skipping annotation "
119
+ f"{image_annotation.get('id', '?')}: segmentation is a dict but "
120
+ "missing 'counts' key (expected RLE format)",
121
+ stacklevel=2,
122
+ )
123
+ masks.append(empty_mask.copy())
124
+ continue
125
+ masks.append(
126
+ rle_to_mask(rle=segmentation["counts"], resolution_wh=resolution_wh)
127
+ )
128
+ continue
129
+
130
+ if not isinstance(segmentation, list):
131
+ masks.append(empty_mask.copy())
132
+ continue
133
+ polygons = segmentation if isinstance(segmentation[0], list) else [segmentation]
134
+
135
+ object_mask = empty_mask.copy()
136
+ for polygon in polygons:
137
+ polygon_array: npt.NDArray[np.int32] = np.reshape(
138
+ np.asarray(polygon, dtype=np.int32), (-1, 2)
139
+ )
140
+ if polygon_array.size == 0:
141
+ warnings.warn(
142
+ "Skipping empty polygon while loading COCO segmentation for "
143
+ f"annotation id={image_annotation.get('id')}.",
144
+ stacklevel=2,
145
+ )
146
+ continue
147
+ # COCO polygon segmentation can contain multiple disjoint parts.
148
+ # Merge all parts into a single per-object mask.
149
+ object_mask |= polygon_to_mask(
150
+ polygon=polygon_array, resolution_wh=resolution_wh
151
+ ).astype(bool)
152
+
153
+ masks.append(object_mask)
154
+
155
+ return np.asarray(masks, dtype=bool)
156
+
157
+
158
+ def coco_annotations_to_detections(
159
+ image_annotations: list[CocoDict],
160
+ resolution_wh: tuple[int, int],
161
+ with_masks: bool,
162
+ use_iscrowd: bool = True,
163
+ ) -> Detections:
164
+ """Convert COCO annotation dicts for a single image into a `Detections` object.
165
+
166
+ .. warning::
167
+ The returned ``Detections.class_id`` contains **raw COCO** ``category_id``
168
+ values, not the final 0-indexed internal class ids. Callers **must** pass
169
+ the result through :func:`map_detections_class_id` with the appropriate
170
+ ``source_to_target_mapping`` (built by
171
+ :func:`build_coco_class_index_mapping`) before the ``class_id`` values are
172
+ meaningful. Skipping the remap step yields 1-based ids in a field that the
173
+ rest of supervision treats as 0-based.
174
+
175
+ Args:
176
+ image_annotations: List of COCO annotation dicts for one image.
177
+ resolution_wh: ``(width, height)`` of the image, used for mask decoding.
178
+ with_masks: Whether to decode segmentation fields into binary masks.
179
+ use_iscrowd: When ``True``, store ``iscrowd`` and ``area`` in
180
+ ``Detections.data``.
181
+
182
+ Returns:
183
+ Detections with ``class_id`` set to raw COCO ``category_id`` values.
184
+ Call :func:`map_detections_class_id` on the result before use.
185
+ When ``with_masks=False``, ``detections.data[COCO_RAW_SEGMENTATION]`` is
186
+ populated as an object array (shape ``(N,)``) holding the raw polygon list or
187
+ RLE dict per annotation; consumed by :func:`detections_to_coco_annotations`
188
+ for a coordinate-preserving round-trip.
189
+ """
190
+ if not image_annotations:
191
+ return Detections.empty()
192
+
193
+ class_ids = [
194
+ image_annotation["category_id"] for image_annotation in image_annotations
195
+ ]
196
+ xyxy_list = [image_annotation["bbox"] for image_annotation in image_annotations]
197
+ xyxy: npt.NDArray[np.float32] = np.asarray(xyxy_list, dtype=np.float32)
198
+ xyxy[:, 2:4] += xyxy[:, 0:2]
199
+
200
+ data: dict[str, npt.NDArray[np.generic] | list[Any]] = {}
201
+ if use_iscrowd:
202
+ iscrowd = [
203
+ image_annotation.get("iscrowd", 0) for image_annotation in image_annotations
204
+ ]
205
+ area = []
206
+ for image_annotation in image_annotations:
207
+ if "area" in image_annotation:
208
+ area.append(image_annotation["area"])
209
+ elif with_masks and _with_seg_mask(image_annotation):
210
+ area.append(np.nan)
211
+ else:
212
+ area.append(image_annotation["bbox"][2] * image_annotation["bbox"][3])
213
+ data = dict(
214
+ iscrowd=np.asarray(iscrowd, dtype=int), area=np.asarray(area, dtype=float)
215
+ )
216
+
217
+ if with_masks:
218
+ mask = coco_annotations_to_masks(
219
+ image_annotations=image_annotations, resolution_wh=resolution_wh
220
+ )
221
+ else:
222
+ mask = None
223
+ # Preserve raw polygon/RLE data so as_coco() can round-trip without
224
+ # binary-mask encoding. Stored as an object array (one entry per detection).
225
+ raw_segs: npt.NDArray[np.object_] = np.empty(
226
+ len(image_annotations), dtype=object
227
+ )
228
+ for k, _ann in enumerate(image_annotations):
229
+ raw_segs[k] = _ann.get("segmentation", [])
230
+ data[COCO_RAW_SEGMENTATION] = raw_segs
231
+
232
+ return Detections(
233
+ class_id=np.asarray(class_ids, dtype=int), xyxy=xyxy, mask=mask, data=data
234
+ )
235
+
236
+
237
+ def detections_to_coco_annotations(
238
+ detections: Detections,
239
+ image_id: int,
240
+ annotation_id: int,
241
+ min_image_area_percentage: float = 0.0,
242
+ max_image_area_percentage: float = 1.0,
243
+ approximation_percentage: float = 0.75,
244
+ ) -> tuple[list[CocoDict], int]:
245
+ """Convert `Detections` to COCO ``annotations`` entries.
246
+
247
+ The internal 0-indexed ``Detections.class_id`` is serialized as a 1-indexed
248
+ COCO ``category_id`` (``category_id = class_id + 1``). This complies with the
249
+ COCO specification and tools such as CVAT, and stays consistent with the ids
250
+ emitted by [`classes_to_coco_categories`](#classes_to_coco_categories), so a
251
+ detection with internal ``class_id=k`` maps to ``category_id=k + 1``.
252
+
253
+ Args:
254
+ detections: The detections to convert. ``class_id`` must not be ``None``.
255
+ image_id: COCO ``image_id`` shared by every produced annotation.
256
+ annotation_id: First annotation id to assign; incremented per detection.
257
+ min_image_area_percentage: Lower bound on detection area / image area,
258
+ used only when approximating masks with polygons.
259
+ max_image_area_percentage: Upper bound on detection area / image area,
260
+ used only when approximating masks with polygons.
261
+ approximation_percentage: Polygon-simplification ratio in ``[0, 1)``.
262
+
263
+ Returns:
264
+ A ``(coco_annotations, next_annotation_id)`` tuple, where
265
+ ``next_annotation_id`` is one greater than the last id assigned.
266
+
267
+ Raises:
268
+ ValueError: If any detection has ``class_id`` equal to ``None``.
269
+
270
+ Note:
271
+ For ``iscrowd=0`` annotations, ``segmentation`` is a
272
+ ``list[list[float]]`` where each inner list encodes one polygon
273
+ part as flat ``[x1, y1, x2, y2, ...]`` coordinates. A single
274
+ object with *N* disjoint parts produces *N* inner lists.
275
+
276
+ When ``iscrowd`` is not in ``detections.data``, masks with holes
277
+ or multiple disjoint segments are auto-encoded as RLE
278
+ (``iscrowd=1``); simple single-region masks use polygon format
279
+ (``iscrowd=0``). Supply ``data={"iscrowd": np.array([0])}`` to
280
+ force polygon output regardless of mask topology.
281
+
282
+ Examples:
283
+ ```python
284
+ import numpy as np
285
+ from supervision import Detections
286
+ from supervision.dataset.formats.coco import (
287
+ detections_to_coco_annotations,
288
+ )
289
+
290
+ detections = Detections(
291
+ xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
292
+ class_id=np.array([0], dtype=int),
293
+ )
294
+ annotations, next_id = detections_to_coco_annotations(
295
+ detections=detections, image_id=1, annotation_id=1
296
+ )
297
+ annotations[0]["category_id"]
298
+ # 1
299
+ ```
300
+ """
301
+ coco_annotations: list[CocoDict] = []
302
+ for xyxy, mask, _, class_id, _, data in detections:
303
+ if class_id is None:
304
+ raise ValueError("Detections must include class_id for COCO export.")
305
+ box_width, box_height = xyxy[2] - xyxy[0], xyxy[3] - xyxy[1]
306
+ segmentation: list[list[float]] | dict[str, list[int]] = []
307
+ if mask is not None:
308
+ mask_bool = mask
309
+ if "iscrowd" in data:
310
+ iscrowd = int(np.asarray(data["iscrowd"]).item())
311
+ else:
312
+ iscrowd = int(
313
+ contains_holes(mask=mask_bool)
314
+ or contains_multiple_segments(mask=mask_bool)
315
+ )
316
+
317
+ if iscrowd:
318
+ segmentation = {
319
+ "counts": cast(
320
+ list[int], mask_to_rle(mask=mask_bool, compressed=False)
321
+ ),
322
+ "size": list(mask.shape[:2]),
323
+ }
324
+ else:
325
+ polygons = approximate_mask_with_polygons(
326
+ mask=mask_bool,
327
+ min_image_area_percentage=min_image_area_percentage,
328
+ max_image_area_percentage=max_image_area_percentage,
329
+ approximation_percentage=approximation_percentage,
330
+ )
331
+ # Small/noisy masks can be filtered out by approximation settings.
332
+ # Guard against empty output and keep a valid COCO annotation record.
333
+ if polygons:
334
+ # Export ALL polygons so disjoint mask components are preserved.
335
+ segmentation = [list(p.flatten()) for p in polygons]
336
+ else:
337
+ warnings.warn(
338
+ "Skipping COCO polygon segmentation for annotation "
339
+ f"id={annotation_id} because mask approximation "
340
+ "returned no polygons.",
341
+ stacklevel=2,
342
+ )
343
+ else:
344
+ iscrowd = int(np.asarray(data.get("iscrowd", 0)).item())
345
+ # When masks were not decoded during loading, fall back to the raw
346
+ # polygon/RLE stored in data["segmentation"] for a lossless round-trip.
347
+ raw_seg = data.get(COCO_RAW_SEGMENTATION)
348
+ if raw_seg is not None and bool(raw_seg):
349
+ if isinstance(raw_seg, dict):
350
+ # RLE format — pass through unchanged
351
+ segmentation = raw_seg
352
+ elif (
353
+ isinstance(raw_seg, list)
354
+ and raw_seg
355
+ and not isinstance(raw_seg[0], (list, tuple))
356
+ ):
357
+ # Flat list shorthand [x1,y1,...] — wrap to list-of-lists
358
+ segmentation = [list(raw_seg)]
359
+ else:
360
+ segmentation = list(raw_seg)
361
+
362
+ stored_area = None
363
+ if "area" in data:
364
+ stored_area = float(np.asarray(data["area"]).item())
365
+
366
+ if stored_area is not None and np.isfinite(stored_area):
367
+ area = stored_area
368
+ elif mask is not None:
369
+ area = float(np.count_nonzero(mask))
370
+ else:
371
+ area = float(box_width * box_height)
372
+ coco_annotation = {
373
+ "id": annotation_id,
374
+ "image_id": image_id,
375
+ "category_id": int(class_id) + 1,
376
+ "bbox": [xyxy[0], xyxy[1], box_width, box_height],
377
+ "area": area,
378
+ "segmentation": segmentation,
379
+ "iscrowd": iscrowd,
380
+ }
381
+ coco_annotations.append(coco_annotation)
382
+ annotation_id += 1
383
+ return coco_annotations, annotation_id
384
+
385
+
386
+ def get_coco_class_index_mapping(annotations_path: str) -> dict[int, int]:
387
+ """
388
+ Generates a mapping from sequential class indices to original COCO class ids.
389
+
390
+ This function is essential when working with models that expect class ids to be
391
+ zero-indexed and sequential (0 to 79), as opposed to the original COCO
392
+ dataset where category ids are non-contiguous ranging from 1 to 90 but skipping some
393
+ ids.
394
+
395
+ Use Cases:
396
+ - Evaluating models trained with COCO-style annotations where class ids
397
+ are sequential ranging from 0 to 79.
398
+ - Ensuring consistent class indexing across training, inference and evaluation,
399
+ when using different tools or datasets with COCO format.
400
+ - Reproducing results from models that assume sequential class ids (0 to 79).
401
+
402
+ How it Works:
403
+ - Reads the COCO annotation file in its original format (`annotations_path`).
404
+ - Extracts and sorts all class names by their original COCO id (1 to 90).
405
+ - Builds a mapping from COCO class ids (not sequential with skipped ids) to
406
+ new class ids (sequential ranging from 0 to 79).
407
+ - Returns a dictionary mapping: `{new_class_id: original_COCO_class_id}`.
408
+
409
+ Args:
410
+ annotations_path: Path to COCO JSON annotations file
411
+ (e.g., `instances_val2017.json`).
412
+
413
+ Returns:
414
+ A mapping from new class id (sequential ranging from 0 to 79)
415
+ to original COCO class id (1 to 90 with skipped ids).
416
+
417
+ Examples:
418
+ >>> import json
419
+ >>> import os
420
+ >>> import tempfile
421
+ >>> from supervision.dataset.formats.coco import get_coco_class_index_mapping
422
+ >>> coco_data = {
423
+ ... "categories": [
424
+ ... {"id": 1, "name": "person"},
425
+ ... {"id": 3, "name": "car"},
426
+ ... ],
427
+ ... "images": [],
428
+ ... "annotations": [],
429
+ ... }
430
+ >>> annotations_path = None
431
+ >>> try:
432
+ ... with tempfile.NamedTemporaryFile(
433
+ ... mode="w", suffix=".json", delete=False
434
+ ... ) as f:
435
+ ... annotations_path = f.name
436
+ ... json.dump(coco_data, f)
437
+ ... mapping = get_coco_class_index_mapping(annotations_path)
438
+ ... print(mapping)
439
+ ... finally:
440
+ ... if annotations_path is not None:
441
+ ... os.remove(annotations_path)
442
+ {0: 1, 1: 3}
443
+ >>> os.path.exists(annotations_path)
444
+ False
445
+ """
446
+ coco_data = read_json_file(annotations_path)
447
+ classes = coco_categories_to_classes(coco_categories=coco_data["categories"])
448
+ class_mapping = build_coco_class_index_mapping(
449
+ coco_categories=coco_data["categories"], target_classes=classes
450
+ )
451
+ return {v: k for k, v in class_mapping.items()}
452
+
453
+
454
+ def load_coco_annotations(
455
+ images_directory_path: str,
456
+ annotations_path: str,
457
+ force_masks: bool = False,
458
+ use_iscrowd: bool = True,
459
+ show_progress: bool = False,
460
+ ) -> tuple[list[str], list[str], dict[str, Detections]]:
461
+ """
462
+ Load COCO annotations and convert them to `Detections`.
463
+
464
+ If `force_masks` is `False`, masks are still loaded for images whose annotations
465
+ include a `segmentation` field. This keeps mask handling consistent with other
466
+ dataset loaders that infer masks from annotation content.
467
+
468
+ Args:
469
+ images_directory_path: Path to the image directory.
470
+ annotations_path: Path to COCO JSON annotations.
471
+ force_masks: If `True`, always attempt to load masks.
472
+ use_iscrowd: If `True`, include `iscrowd` and `area` in detection data.
473
+ show_progress: If `True`, display a progress bar during loading.
474
+
475
+ Returns:
476
+ A tuple of `(classes, image_paths, annotations)` where image paths are
477
+ canonicalized resolved paths inside ``images_directory_path``.
478
+
479
+ Raises:
480
+ ValueError: If any annotation's ``file_name`` resolves to the images
481
+ directory itself, to a path outside the images directory (e.g. via
482
+ ``../`` traversal or an absolute path), or to a subdirectory instead
483
+ of a regular image file.
484
+ ValueError: If two image entries resolve to the same canonical path.
485
+
486
+ Note:
487
+ Each annotation's ``file_name`` is validated against
488
+ ``images_directory_path`` before loading. Annotations that reference
489
+ paths outside the directory are rejected to prevent path-traversal
490
+ attacks when loading user-supplied annotation files. Symlinked images
491
+ pointing outside the resolved images directory are also rejected.
492
+ """
493
+ coco_data = read_json_file(file_path=annotations_path)
494
+ classes = coco_categories_to_classes(coco_categories=coco_data["categories"])
495
+
496
+ class_index_mapping = build_coco_class_index_mapping(
497
+ coco_categories=coco_data["categories"], target_classes=classes
498
+ )
499
+
500
+ coco_images = coco_data["images"]
501
+ coco_annotations_groups = group_coco_annotations_by_image_id(
502
+ coco_annotations=coco_data["annotations"]
503
+ )
504
+
505
+ images = []
506
+ annotations = {}
507
+ images_directory_resolved = Path(images_directory_path).resolve()
508
+
509
+ for coco_image in tqdm(
510
+ coco_images,
511
+ total=len(coco_images),
512
+ desc="Loading COCO annotations",
513
+ disable=not show_progress,
514
+ ):
515
+ image_name, image_width, image_height = (
516
+ coco_image["file_name"],
517
+ coco_image["width"],
518
+ coco_image["height"],
519
+ )
520
+ image_annotations = coco_annotations_groups.get(coco_image["id"], [])
521
+ image_path = str(Path(images_directory_path) / Path(image_name))
522
+ try:
523
+ resolved_image_path = Path(image_path).resolve()
524
+ except (OSError, ValueError) as exc:
525
+ raise ValueError(
526
+ f"COCO annotation refers to image {image_name!r}, which "
527
+ f"produces an invalid path: {exc}"
528
+ ) from exc
529
+ if resolved_image_path == images_directory_resolved:
530
+ raise ValueError(
531
+ f"COCO annotation refers to image {image_name!r}, which "
532
+ f"resolves to the images directory itself "
533
+ f"({images_directory_resolved}). Expected a path to an "
534
+ "image file."
535
+ )
536
+ if images_directory_resolved not in resolved_image_path.parents:
537
+ raise ValueError(
538
+ f"COCO annotation refers to image {image_name!r}, which "
539
+ f"resolves to {resolved_image_path} — outside the images "
540
+ f"directory {images_directory_resolved}."
541
+ )
542
+ if resolved_image_path.is_dir():
543
+ raise ValueError(
544
+ f"COCO annotation refers to image {image_name!r}, which "
545
+ f"resolves to directory {resolved_image_path}. Expected a "
546
+ "path to an image file."
547
+ )
548
+ image_path = str(resolved_image_path)
549
+ if image_path in annotations:
550
+ raise ValueError(
551
+ f"COCO annotation file contains duplicate entries for image "
552
+ f"{image_name!r}. Each image must appear at most once."
553
+ )
554
+
555
+ with_masks = force_masks or any(
556
+ _with_seg_mask(annotation) for annotation in image_annotations
557
+ )
558
+ annotation = coco_annotations_to_detections(
559
+ image_annotations=image_annotations,
560
+ resolution_wh=(image_width, image_height),
561
+ with_masks=with_masks,
562
+ use_iscrowd=use_iscrowd,
563
+ )
564
+
565
+ annotation = map_detections_class_id(
566
+ source_to_target_mapping=class_index_mapping,
567
+ detections=annotation,
568
+ )
569
+
570
+ images.append(image_path)
571
+ annotations[image_path] = annotation
572
+
573
+ return classes, images, annotations
574
+
575
+
576
+ def _with_seg_mask(annotation: dict[str, Any]) -> bool:
577
+ return bool(annotation.get("segmentation"))
578
+
579
+
580
+ def save_coco_annotations(
581
+ dataset: DetectionDataset,
582
+ annotation_path: str,
583
+ min_image_area_percentage: float = 0.0,
584
+ max_image_area_percentage: float = 1.0,
585
+ approximation_percentage: float = 0.0,
586
+ starting_image_id: int = 1,
587
+ starting_annotation_id: int = 1,
588
+ show_progress: bool = False,
589
+ ) -> tuple[int, int]:
590
+ """Save a DetectionDataset to a COCO-format ``annotations.json`` file.
591
+
592
+ Args:
593
+ dataset: The DetectionDataset to write.
594
+ annotation_path: Output path for the COCO ``annotations.json``.
595
+ min_image_area_percentage: Lower bound on detection area / image area;
596
+ used only for segmentation datasets.
597
+ max_image_area_percentage: Upper bound on detection area / image area;
598
+ used only for segmentation datasets.
599
+ approximation_percentage: Polygon-simplification ratio in ``[0, 1)``;
600
+ used only for segmentation datasets.
601
+ starting_image_id: First image id to assign in the exported file.
602
+ Defaults to ``1``. Override when exporting multiple splits into
603
+ a coordinated COCO collection so ids remain unique across the set.
604
+ starting_annotation_id: First annotation id to assign in the exported
605
+ file. Defaults to ``1``. Override for the same multi-split reason
606
+ as ``starting_image_id``.
607
+ show_progress: If ``True``, display a progress bar during saving.
608
+
609
+ Returns:
610
+ A ``(next_image_id, next_annotation_id)`` tuple. The returned values
611
+ are one greater than the highest ids written, so they can be fed
612
+ directly back into ``starting_image_id`` and ``starting_annotation_id``
613
+ when exporting another split into a coordinated COCO collection
614
+ (see ``DetectionDataset.as_coco`` for the chaining pattern). When the
615
+ dataset is empty the starting ids are returned unchanged.
616
+
617
+ .. note::
618
+ This function ensures globally unique integer ``id`` values across
619
+ splits. It rejects duplicate image basenames before writing because
620
+ ``file_name`` is set to the bare image basename, so two input paths
621
+ that differ only by directory would otherwise collapse to the same
622
+ COCO image record.
623
+
624
+ Raises:
625
+ ValueError: If two image paths share the same basename and would map to
626
+ the same COCO ``file_name``.
627
+
628
+ Example:
629
+ ```python
630
+ import supervision as sv
631
+ from supervision.dataset.formats.coco import save_coco_annotations
632
+
633
+ ds = sv.DetectionDataset.from_yolo(
634
+ images_directory_path="train/images",
635
+ annotations_directory_path="train/labels",
636
+ data_yaml_path="data.yaml",
637
+ )
638
+ next_img_id, next_ann_id = save_coco_annotations(
639
+ dataset=ds, annotation_path="out/train/annotations.json"
640
+ )
641
+ # next_img_id and next_ann_id are the first unused ids — pass them
642
+ # to the next split to keep ids globally unique across files.
643
+ ```
644
+ """
645
+ if starting_image_id < 1 or starting_annotation_id < 1:
646
+ raise ValueError(
647
+ "starting_image_id and starting_annotation_id must be >= 1 "
648
+ "(COCO spec requires 1-indexed ids); "
649
+ f"got {starting_image_id=}, {starting_annotation_id=}"
650
+ )
651
+ check_no_basename_collisions(
652
+ image_paths=dataset.image_paths,
653
+ key=lambda image_path: Path(image_path).name,
654
+ output_kind="COCO image",
655
+ )
656
+ Path(annotation_path).parent.mkdir(parents=True, exist_ok=True)
657
+ licenses = [
658
+ {
659
+ "id": 1,
660
+ "url": "https://creativecommons.org/licenses/by/4.0/",
661
+ "name": "CC BY 4.0",
662
+ }
663
+ ]
664
+
665
+ coco_annotations = []
666
+ coco_images = []
667
+ coco_categories = classes_to_coco_categories(classes=dataset.classes)
668
+
669
+ image_id, annotation_id = starting_image_id, starting_annotation_id
670
+ for image_path, image, annotation in tqdm(
671
+ dataset,
672
+ total=len(dataset),
673
+ desc="Saving COCO annotations",
674
+ disable=not show_progress,
675
+ ):
676
+ image_height, image_width, _ = image.shape
677
+ image_name = f"{Path(image_path).stem}{Path(image_path).suffix}"
678
+ coco_image = {
679
+ "id": image_id,
680
+ "license": 1,
681
+ "file_name": image_name,
682
+ "height": image_height,
683
+ "width": image_width,
684
+ "date_captured": datetime.now().strftime("%m/%d/%Y,%H:%M:%S"),
685
+ }
686
+
687
+ coco_images.append(coco_image)
688
+ coco_annotation, annotation_id = detections_to_coco_annotations(
689
+ detections=annotation,
690
+ image_id=image_id,
691
+ annotation_id=annotation_id,
692
+ min_image_area_percentage=min_image_area_percentage,
693
+ max_image_area_percentage=max_image_area_percentage,
694
+ approximation_percentage=approximation_percentage,
695
+ )
696
+
697
+ coco_annotations.extend(coco_annotation)
698
+ image_id += 1
699
+
700
+ annotation_dict = {
701
+ "info": {},
702
+ "licenses": licenses,
703
+ "categories": coco_categories,
704
+ "images": coco_images,
705
+ "annotations": coco_annotations,
706
+ }
707
+ save_json_file(annotation_dict, file_path=annotation_path)
708
+ return image_id, annotation_id