Skip to content

Commit 83d357d

Browse files
authored
Merge pull request #100 from roboflow/feature/support_for_yolo_dataset_export
feature/support for yolo dataset export
2 parents ba29266 + b536c6c commit 83d357d

File tree

12 files changed

+336
-41
lines changed

12 files changed

+336
-41
lines changed

docs/changelog.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
### 0.8.0 <small>May 17, 2023</small>
2+
3+
- Added [[#100](https://github.com/roboflow/supervision/pull/100)]: support for Dataset inheritance. Current `Dataset` got renamed to `DetectionDataset` and make it inherit from `BaseDataset`.
4+
- Added [[#100](https://github.com/roboflow/supervision/pull/100)]: ability to save datasets in YOLO format using `DetectionDataset.as_yolo`.
5+
- Changed [[#100](https://github.com/roboflow/supervision/pull/100)]: default value of `approximation_percentage` parameter from `0.75` to `0.0` in `DetectionDataset.as_yolo` and `DetectionDataset.as_pascal_voc`.
6+
17
### 0.7.0 <small>May 11, 2023</small>
28

39
- Added [[#91](https://github.com/roboflow/supervision/pull/91)]: `Detections.from_yolo_nas` to enable seamless integration with [YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) model.

docs/dataset/core.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
!!! warning
22

3-
`Dataset` API is still fluid and may change. If you use Dataset in your project until further notice, freeze the
4-
`supervision` version in your `requirements.txt`.
3+
Dataset API is still fluid and may change. If you use Dataset API in your project until further notice, freeze the
4+
`supervision` version in your `requirements.txt` or `setup.py`.
55

6-
## Dataset
6+
## DetectionDataset
77

8-
:::supervision.dataset.core.Dataset
8+
:::supervision.dataset.core.DetectionDataset

setup.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ def get_version():
2626
install_requires=[
2727
'numpy>=1.20.0',
2828
'opencv-python',
29-
'matplotlib'
29+
'matplotlib',
30+
'pyyaml'
3031
],
3132
packages=find_packages(exclude=("tests",)),
3233
extras_require={

supervision/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
__version__ = "0.7.0"
1+
__version__ = "0.8.0"
22

3-
from supervision.dataset.core import Dataset
3+
from supervision.dataset.core import BaseDataset, DetectionDataset
44
from supervision.detection.annotate import BoxAnnotator, MaskAnnotator
55
from supervision.detection.core import Detections
66
from supervision.detection.line_counter import LineZone, LineZoneAnnotator

supervision/dataset/core.py

Lines changed: 67 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from dataclasses import dataclass
44
from pathlib import Path
5-
from typing import Dict, List, Optional, Tuple, Iterator
5+
from typing import Dict, Iterator, List, Optional, Tuple
66

77
import cv2
88
import numpy as np
@@ -11,15 +11,25 @@
1111
detections_to_pascal_voc,
1212
load_pascal_voc_annotations,
1313
)
14-
from supervision.dataset.formats.yolo import load_yolo_annotations
14+
from supervision.dataset.formats.yolo import (
15+
load_yolo_annotations,
16+
save_data_yaml,
17+
save_yolo_annotations,
18+
)
19+
from supervision.dataset.ultils import save_dataset_images
1520
from supervision.detection.core import Detections
1621
from supervision.file import list_files_with_extensions
1722

1823

1924
@dataclass
20-
class Dataset:
25+
class BaseDataset:
26+
pass
27+
28+
29+
@dataclass
30+
class DetectionDataset(BaseDataset):
2131
"""
22-
Dataclass containing information about the dataset.
32+
Dataclass containing information about object detection dataset.
2333
2434
Attributes:
2535
classes (List[str]): List containing dataset class names.
@@ -57,7 +67,7 @@ def as_pascal_voc(
5767
annotations_directory_path: Optional[str] = None,
5868
min_image_area_percentage: float = 0.0,
5969
max_image_area_percentage: float = 1.0,
60-
approximation_percentage: float = 0.75,
70+
approximation_percentage: float = 0.0,
6171
) -> None:
6272
"""
6373
Exports the dataset to PASCAL VOC format. This method saves the images and their corresponding annotations in
@@ -107,7 +117,7 @@ def as_pascal_voc(
107117
@classmethod
108118
def from_pascal_voc(
109119
cls, images_directory_path: str, annotations_directory_path: str
110-
) -> Dataset:
120+
) -> DetectionDataset:
111121
"""
112122
Creates a Dataset instance from PASCAL VOC formatted data.
113123
@@ -116,7 +126,7 @@ def from_pascal_voc(
116126
annotations_directory_path (str): The path to the directory containing the PASCAL VOC XML annotations.
117127
118128
Returns:
119-
Dataset: A Dataset instance containing the loaded images and annotations.
129+
DetectionDataset: A DetectionDataset instance containing the loaded images and annotations.
120130
121131
Example:
122132
```python
@@ -168,7 +178,7 @@ def from_pascal_voc(
168178
annotations = {
169179
image_name: detections for image_name, detections, _ in raw_annotations
170180
}
171-
return Dataset(classes=classes, images=images, annotations=annotations)
181+
return DetectionDataset(classes=classes, images=images, annotations=annotations)
172182

173183
@classmethod
174184
def from_yolo(
@@ -177,7 +187,7 @@ def from_yolo(
177187
annotations_directory_path: str,
178188
data_yaml_path: str,
179189
force_masks: bool = False,
180-
) -> Dataset:
190+
) -> DetectionDataset:
181191
"""
182192
Creates a Dataset instance from YOLO formatted data.
183193
@@ -188,7 +198,7 @@ def from_yolo(
188198
force_masks (bool, optional): If True, forces masks to be loaded for all annotations, regardless of whether they are present.
189199
190200
Returns:
191-
Dataset: A Dataset instance containing the loaded images and annotations.
201+
DetectionDataset: A DetectionDataset instance containing the loaded images and annotations.
192202
193203
Example:
194204
```python
@@ -219,4 +229,50 @@ def from_yolo(
219229
data_yaml_path=data_yaml_path,
220230
force_masks=force_masks,
221231
)
222-
return Dataset(classes=classes, images=images, annotations=annotations)
232+
return DetectionDataset(classes=classes, images=images, annotations=annotations)
233+
234+
def as_yolo(
235+
self,
236+
images_directory_path: Optional[str] = None,
237+
annotations_directory_path: Optional[str] = None,
238+
data_yaml_path: Optional[str] = None,
239+
min_image_area_percentage: float = 0.0,
240+
max_image_area_percentage: float = 1.0,
241+
approximation_percentage: float = 0.0,
242+
) -> None:
243+
"""
244+
Exports the dataset to YOLO (You Only Look Once) format. This method saves the images and their corresponding
245+
annotations in YOLO format, which is a simple text file that describes an object in the image. It also allows
246+
for the optional saving of a data.yaml file, used in YOLOv5, that contains metadata about the dataset.
247+
248+
The method allows filtering the detections based on their area percentage and offers an option for polygon approximation.
249+
250+
Args:
251+
images_directory_path (Optional[str]): The path to the directory where the images should be saved.
252+
If not provided, images will not be saved.
253+
annotations_directory_path (Optional[str]): The path to the directory where the annotations in
254+
YOLO format should be saved. If not provided, annotations will not be saved.
255+
data_yaml_path (Optional[str]): The path where the data.yaml file should be saved.
256+
If not provided, the file will not be saved.
257+
min_image_area_percentage (float): The minimum percentage of detection area relative to
258+
the image area for a detection to be included.
259+
max_image_area_percentage (float): The maximum percentage of detection area relative to
260+
the image area for a detection to be included.
261+
approximation_percentage (float): The percentage of polygon points to be removed from the input polygon,
262+
in the range [0, 1). This is useful for simplifying the annotations.
263+
"""
264+
if images_directory_path is not None:
265+
save_dataset_images(
266+
images_directory_path=images_directory_path, images=self.images
267+
)
268+
if annotations_directory_path is not None:
269+
save_yolo_annotations(
270+
annotations_directory_path=annotations_directory_path,
271+
images=self.images,
272+
annotations=self.annotations,
273+
min_image_area_percentage=min_image_area_percentage,
274+
max_image_area_percentage=max_image_area_percentage,
275+
approximation_percentage=approximation_percentage,
276+
)
277+
if data_yaml_path is not None:
278+
save_data_yaml(data_yaml_path=data_yaml_path, classes=self.classes)

supervision/dataset/formats/pascal_voc.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,6 @@ def detections_to_pascal_voc(
6363
str: An XML string in Pascal VOC format representing the detections.
6464
"""
6565
height, width, depth = image_shape
66-
image_area = height * width
67-
minimum_detection_area = min_image_area_percentage * image_area
68-
maximum_detection_area = max_image_area_percentage * image_area
6966

7067
# Create root element
7168
annotation = Element("annotation")

supervision/dataset/formats/yolo.py

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1+
import os
12
from pathlib import Path
2-
from typing import Dict, List, Tuple, Union
3+
from typing import Dict, List, Optional, Tuple, Union
34

45
import cv2
56
import numpy as np
7+
import yaml
68

9+
from supervision.dataset.ultils import approximate_mask_with_polygons
710
from supervision.detection.core import Detections
811
from supervision.detection.utils import polygon_to_mask, polygon_to_xyxy
9-
from supervision.file import list_files_with_extensions, read_txt_file
12+
from supervision.file import list_files_with_extensions, read_txt_file, save_text_file
1013

1114

1215
def _parse_box(values: List[str]) -> np.ndarray:
@@ -78,6 +81,11 @@ def _extract_class_names(file_path: str) -> List[str]:
7881
return names
7982

8083

84+
def _image_name_to_annotation_name(image_name: str) -> str:
85+
base_name, _ = os.path.splitext(image_name)
86+
return base_name + ".txt"
87+
88+
8189
def yolo_annotations_to_detections(
8290
lines: List[str], resolution_wh: Tuple[int, int], with_masks: bool
8391
) -> Detections:
@@ -130,7 +138,7 @@ def load_yolo_annotations(
130138
force_masks (bool, optional): If True, forces masks to be loaded for all annotations, regardless of whether they are present.
131139
132140
Returns:
133-
Tuple[List[str], Dict[str, np.ndarray], Dict[str, Detections]]: A tuple containing a list of class names, a dictionary with image paths as keys and images as values, and a dictionary with image paths as keys and corresponding Detections instances as values.
141+
Tuple[List[str], Dict[str, np.ndarray], Dict[str, Detections]]: A tuple containing a list of class names, a dictionary with image names as keys and images as values, and a dictionary with image names as keys and corresponding Detections instances as values.
134142
"""
135143
image_paths = list_files_with_extensions(
136144
directory=images_directory_path, extensions=["jpg", "jpeg", "png"]
@@ -156,6 +164,93 @@ def load_yolo_annotations(
156164
lines=lines, resolution_wh=resolution_wh, with_masks=with_masks
157165
)
158166

159-
images[str(image_path)] = image
160-
annotations[str(image_path)] = annotation
167+
images[image_path.name] = image
168+
annotations[image_path.name] = annotation
161169
return classes, images, annotations
170+
171+
172+
def object_to_yolo(
173+
xyxy: np.ndarray,
174+
class_id: int,
175+
image_shape: Tuple[int, int, int],
176+
polygon: Optional[np.ndarray] = None,
177+
) -> str:
178+
h, w, _ = image_shape
179+
if polygon is None:
180+
xyxy_relative = xyxy / np.array([w, h, w, h], dtype=np.float32)
181+
x_min, y_min, x_max, y_max = xyxy_relative
182+
x_center = (x_min + x_max) / 2
183+
y_center = (y_min + y_max) / 2
184+
width = x_max - x_min
185+
height = y_max - y_min
186+
return f"{int(class_id)} {x_center:.5f} {y_center:.5f} {width:.5f} {height:.5f}"
187+
else:
188+
polygon_relative = polygon / np.array([w, h], dtype=np.float32)
189+
polygon_relative = polygon_relative.reshape(-1)
190+
polygon_parsed = " ".join([f"{value:.5f}" for value in polygon_relative])
191+
return f"{int(class_id)} {polygon_parsed}"
192+
193+
194+
def detections_to_yolo_annotations(
195+
detections: Detections,
196+
image_shape: Tuple[int, int, int],
197+
min_image_area_percentage: float = 0.0,
198+
max_image_area_percentage: float = 1.0,
199+
approximation_percentage: float = 0.75,
200+
) -> List[str]:
201+
annotation = []
202+
for xyxy, mask, _, class_id, _ in detections:
203+
if mask is not None:
204+
polygons = approximate_mask_with_polygons(
205+
mask=mask,
206+
min_image_area_percentage=min_image_area_percentage,
207+
max_image_area_percentage=max_image_area_percentage,
208+
approximation_percentage=approximation_percentage,
209+
)
210+
for polygon in polygons:
211+
xyxy = polygon_to_xyxy(polygon=polygon)
212+
next_object = object_to_yolo(
213+
xyxy=xyxy,
214+
class_id=class_id,
215+
image_shape=image_shape,
216+
polygon=polygon,
217+
)
218+
annotation.append(next_object)
219+
else:
220+
next_object = object_to_yolo(
221+
xyxy=xyxy, class_id=class_id, image_shape=image_shape
222+
)
223+
annotation.append(next_object)
224+
return annotation
225+
226+
227+
def save_yolo_annotations(
228+
annotations_directory_path: str,
229+
images: Dict[str, np.ndarray],
230+
annotations: Dict[str, Detections],
231+
min_image_area_percentage: float = 0.0,
232+
max_image_area_percentage: float = 1.0,
233+
approximation_percentage: float = 0.75,
234+
) -> None:
235+
Path(annotations_directory_path).mkdir(parents=True, exist_ok=True)
236+
for image_name, image in images.items():
237+
detections = annotations[image_name]
238+
yolo_annotations_name = _image_name_to_annotation_name(image_name=image_name)
239+
yolo_annotations_path = os.path.join(
240+
annotations_directory_path, yolo_annotations_name
241+
)
242+
lines = detections_to_yolo_annotations(
243+
detections=detections,
244+
image_shape=image.shape,
245+
min_image_area_percentage=min_image_area_percentage,
246+
max_image_area_percentage=max_image_area_percentage,
247+
approximation_percentage=approximation_percentage,
248+
)
249+
save_text_file(lines=lines, file_path=yolo_annotations_path)
250+
251+
252+
def save_data_yaml(data_yaml_path: str, classes: List[str]) -> None:
253+
data = {"nc": len(classes), "names": classes}
254+
Path(data_yaml_path).parent.mkdir(parents=True, exist_ok=True)
255+
with open(data_yaml_path, "w") as outfile:
256+
yaml.dump(data, outfile, default_flow_style=False)

supervision/dataset/ultils.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
from typing import List
1+
import os
2+
from pathlib import Path
3+
from typing import Dict, List
24

5+
import cv2
36
import numpy as np
47

58
from supervision.detection.utils import (
@@ -15,7 +18,7 @@ def approximate_mask_with_polygons(
1518
max_image_area_percentage: float = 1.0,
1619
approximation_percentage: float = 0.75,
1720
) -> List[np.ndarray]:
18-
height, width = mask
21+
height, width = mask.shape
1922
image_area = height * width
2023
minimum_detection_area = min_image_area_percentage * image_area
2124
maximum_detection_area = max_image_area_percentage * image_area
@@ -35,3 +38,13 @@ def approximate_mask_with_polygons(
3538
approximate_polygon(polygon=polygon, percentage=approximation_percentage)
3639
for polygon in polygons
3740
]
41+
42+
43+
def save_dataset_images(
44+
images_directory_path: str, images: Dict[str, np.ndarray]
45+
) -> None:
46+
Path(images_directory_path).mkdir(parents=True, exist_ok=True)
47+
48+
for image_name, image in images.items():
49+
target_image_path = os.path.join(images_directory_path, image_name)
50+
cv2.imwrite(target_image_path, image)

0 commit comments

Comments
 (0)