diff --git a/docs/changelog.md b/docs/changelog.md index 024edd1a..834cae82 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,3 +1,15 @@ +### 0.22.0 Jul X, 2024 + +- Detections dataset now accepts a list of image paths, lazy-loading images when needed. This enables use cases when you have a large dataset and don't want to load all images into memory at once. + +!!! failure "Deprecated" + + Constructing `DetectionDataset` with parameter `images` as `Dict[str, np.ndarray]` is deprecated and will be removed in `supervision-0.26.0`. Please pass a list of paths `List[str]` instead. + +!!! failure "Deprecated" + + The `DetectionDataset.images` property is deprecated and will be removed in `supervision-0.26.0`. Please loop over images with `for path, image, annotation in dataset:`, as that does not require loading all images into memory. + ### 0.21.0 Jun 5, 2024 - Added [#500](https://github.com/roboflow/supervision/pull/500): [`sv.Detections.with_nmm`](https://supervision.roboflow.com/develop/detection/core/#supervision.detection.core.Detections.with_nmm) to perform non-maximum merging on the current set of object detections. @@ -164,7 +176,7 @@ with csv_sink: - Added [#819](https://github.com/roboflow/supervision/pull/819): [`sv.JSONSink`](/0.19.0/detection/tools/save_detections/#supervision.detection.tools.csv_sink.JSONSink) allowing for the straightforward saving of image, video, or stream inference results in a `.json` file. -```python +````python ```python import supervision as sv @@ -179,7 +191,7 @@ with json_sink: result = model(frame)[0] detections = sv.Detections.from_ultralytics(result) json_sink.append(detections, custom_data={:}) -``` +```` - Added [#847](https://github.com/roboflow/supervision/pull/847): [`sv.mask_iou_batch`](/0.19.0/detection/utils/#supervision.detection.utils.mask_iou_batch) allowing to compute Intersection over Union (IoU) of two sets of masks. @@ -285,7 +297,6 @@ ColorPalette(colors=[Color(r=68, g=1, b=84), Color(r=59, g=82, b=139), ...]) `sv.ColorPalette.default()` is deprecated and will be removed in `supervision-0.22.0`. Use `sv.ColorPalette.DEFAULT` instead. - - Changed [#769](https://github.com/roboflow/supervision/pull/769): [`sv.ColorPalette.DEFAULT`](/0.18.0/draw/color/#colorpalette) value, giving users a more extensive set of annotation colors. - Changed [#677](https://github.com/roboflow/supervision/pull/677): `sv.Detections.from_roboflow` to [`sv.Detections.from_inference`](/0.18.0/detection/core/#supervision.detection.core.Detections.from_inference) streamlining its functionality to be compatible with both the both [inference](https://github.com/roboflow/inference) pip package and the Robloflow [hosted API](https://docs.roboflow.com/deploy/hosted-api). @@ -294,7 +305,6 @@ ColorPalette(colors=[Color(r=68, g=1, b=84), Color(r=59, g=82, b=139), ...]) `Detections.from_roboflow()` is deprecated and will be removed in `supervision-0.22.0`. Use `Detections.from_inference` instead. - - Fixed [#735](https://github.com/roboflow/supervision/pull/735): [`sv.LineZone`](/0.18.0/detection/tools/line_zone/#linezone) functionality to accurately update the counter when an object crosses a line from any direction, including from the side. This enhancement enables more precise tracking and analytics, such as calculating individual in/out counts for each lane on the road. ### 0.17.0 December 06, 2023 @@ -387,13 +397,12 @@ ColorPalette(colors=[Color(r=68, g=1, b=84), Color(r=59, g=82, b=139), ...]) - Fixed [#477](https://github.com/roboflow/supervision/pull/477): Poetry env definition allowing proper local installation. -- Fixed [#430](https://github.com/roboflow/supervision/pull/430): [`sv.ByteTrack`](/0.16.0/trackers/#supervision.tracker.byte_tracker.core.ByteTrack) to return `np.array([], dtype=int)` when `svDetections` is empty. +- Fixed [#430](https://github.com/roboflow/supervision/pull/430): [`sv.ByteTrack`](/0.16.0/trackers/#supervision.tracker.byte_tracker.core.ByteTrack) to return `np.array([], dtype=int)` when `svDetections` is empty. !!! failure "Deprecated" `sv.Detections.from_yolov8` and `sv.Classifications.from_yolov8` as those are now replaced by [`sv.Detections.from_ultralytics`](/0.16.0/detection/core/#supervision.detection.core.Detections.from_ultralytics) and [`sv.Classifications.from_ultralytics`](/0.16.0/classification/core/#supervision.classification.core.Classifications.from_ultralytics). - ### 0.15.0 October 5, 2023 - Added [#170](https://github.com/roboflow/supervision/pull/170): [`sv.BoundingBoxAnnotator`](/0.15.0/annotators/#supervision.annotators.core.BoundingBoxAnnotator) allowing to annotate images and videos with bounding boxes. diff --git a/docs/deprecated.md b/docs/deprecated.md index 5834b05f..340a5104 100644 --- a/docs/deprecated.md +++ b/docs/deprecated.md @@ -17,3 +17,5 @@ These features are phased out due to better alternatives or potential issues in - The `track_buffer`, `track_thresh`, and `match_thresh` parameters in [`ByterTrack`](trackers.md/#supervision.tracker.byte_tracker.core.ByteTrack) are deprecated and will be removed in `supervision-0.23.0`. Use `lost_track_buffer,` `track_activation_threshold`, and `minimum_matching_threshold` instead. - The `triggering_position ` parameter in [`sv.PolygonZone`](detection/tools/polygon_zone.md/#supervision.detection.tools.polygon_zone.PolygonZone) is deprecated and will be removed in `supervision-0.23.0`. Use `triggering_anchors ` instead. - The `frame_resolution_wh ` parameter in [`sv.PolygonZone`](detection/tools/polygon_zone.md/#supervision.detection.tools.polygon_zone.PolygonZone) is deprecated and will be removed in `supervision-0.24.0`. +- Constructing `DetectionDataset` with parameter `images` as `Dict[str, np.ndarray]` is deprecated and will be removed in `supervision-0.26.0`. Please pass a list of paths `List[str]` instead. +- The `DetectionDataset.images` property is deprecated and will be removed in `supervision-0.26.0`. Please loop over images with `for path, image, annotation in dataset:`, as that does not require loading all images into memory. diff --git a/supervision/dataset/core.py b/supervision/dataset/core.py index e6d69f76..b3203669 100644 --- a/supervision/dataset/core.py +++ b/supervision/dataset/core.py @@ -3,8 +3,9 @@ from __future__ import annotations import os from abc import ABC, abstractmethod from dataclasses import dataclass +from itertools import chain from pathlib import Path -from typing import Dict, Iterator, List, Optional, Tuple +from typing import Dict, Iterator, List, Optional, Tuple, Union import cv2 import numpy as np @@ -27,13 +28,13 @@ from supervision.dataset.utils import ( build_class_index_mapping, map_detections_class_id, merge_class_lists, - save_dataset_images, train_test_split, ) from supervision.detection.core import Detections +from supervision.utils.internal import deprecated, warn_deprecated +from supervision.utils.iterables import find_duplicates -@dataclass class BaseDataset(ABC): @abstractmethod def __len__(self) -> int: @@ -46,21 +47,68 @@ class BaseDataset(ABC): pass -@dataclass class DetectionDataset(BaseDataset): """ Dataclass containing information about object detection dataset. Attributes: classes (List[str]): List containing dataset class names. - images (Dict[str, np.ndarray]): Dictionary mapping image name to image. + images (Union[List[str], Dict[str, np.ndarray]]): + Accepts a list of image paths, or dictionaries of loaded cv2 images + with paths as keys. If you pass a list of paths, the dataset will + lazily load images on demand, which is much more memory-efficient. annotations (Dict[str, Detections]): Dictionary mapping - image name to annotations. + image path to detections. """ - classes: List[str] - images: Dict[str, np.ndarray] - annotations: Dict[str, Detections] + def __init__( + self, + classes: List[str], + images: Union[List[str], Dict[str, np.ndarray]], + annotations: Dict[str, Detections], + ) -> None: + self.classes = classes + self.annotations = annotations + + self._image_paths_as_unique_keys = dict.fromkeys(images) + self.image_paths = list(self._image_paths_as_unique_keys) + + self._images_in_memory: Dict[str, np.ndarray] = {} + if isinstance(images, dict): + self._images_in_memory = images + warn_deprecated( + "Passing a `Dict[str, np.ndarray]` into `DetectionDataset` is deprecated and " + "will be removed in `supervision-0.26.0`. Use a list of paths `List[str]` " + "instead." + ) + # TODO: when supervision-0.26.0 is released, and images: Dict[str, np.ndarray] is + # no longer supported, also simplify the rest of the code. E.g. list(images) + # is no longer needed, and merge can be simplified. + + @property + @deprecated( + "`DetectionDataset.images` property is deprecated and will be removed in " + "`supervision-0.26.0`. Iterate with `for path, image, annotation in dataset:` instead." + ) + def images(self) -> Dict[str, np.ndarray]: + """ + Load all images to memory and return them as a dictionary. + + Warning: only use this when you need all images at once. + It is much more memory-efficient to initialize dataset with + image paths and use `for image in dataset:`. + """ + if self._images_in_memory: + return self._images_in_memory + + images = {image_path: cv2.imread(image_path) for image_path in self.image_paths} + return images + + def _get_image(self, image_path: str) -> np.ndarray: + """Assumes that image is in dataset""" + if self._images_in_memory: + return self._images_in_memory[image_path] + return cv2.imread(image_path) def __len__(self) -> int: """ @@ -69,7 +117,13 @@ class DetectionDataset(BaseDataset): Returns: int: The number of images. """ - return len(self.images) + return len(self._images_in_memory) or len(self.image_paths) + + def __getitem__(self, i: int) -> Tuple[str, np.ndarray, Detections]: + image_path = self.image_paths[i] + image = self._get_image(image_path) + annotation = self.annotations[image_path] + return image_path, image, annotation def __iter__(self) -> Iterator[Tuple[str, np.ndarray, Detections]]: """ @@ -80,21 +134,30 @@ class DetectionDataset(BaseDataset): An iterator that yields tuples containing the image name, the image data, and its corresponding annotation. """ - for image_name, image in self.images.items(): - yield image_name, image, self.annotations.get(image_name, None) + for i in range(len(self)): + image_path, image, annotation = self[i] + yield image_path, image, annotation - def __eq__(self, other): + def __eq__(self, other) -> bool: if not isinstance(other, DetectionDataset): return False if set(self.classes) != set(other.classes): return False - for key in self.images: - if not np.array_equal(self.images[key], other.images[key]): - return False - if not self.annotations[key] == other.annotations[key]: - return False + if self.image_paths != other.image_paths: + return False + + try: + for self_values, other_values in zip(self, other): + _, image_self, annotation_self = self_values + _, image_other, annotation_other = other_values + if not np.array_equal(image_self, image_other): + return False + if not annotation_self == annotation_other: + return False + except KeyError: + return False return True @@ -127,26 +190,131 @@ class DetectionDataset(BaseDataset): ``` """ - image_names = list(self.images.keys()) - train_names, test_names = train_test_split( - data=image_names, + train_paths, test_paths = train_test_split( + data=self.image_paths, train_ratio=split_ratio, random_state=random_state, shuffle=shuffle, ) + train_input: Union[List[str], Dict[str, np.ndarray]] + test_input: Union[List[str], Dict[str, np.ndarray]] + if self._images_in_memory: + train_input = {path: self._images_in_memory[path] for path in train_paths} + test_input = {path: self._images_in_memory[path] for path in test_paths} + else: + train_input = train_paths + test_input = test_paths + train_annotations = {path: self.annotations[path] for path in train_paths} + test_annotations = {path: self.annotations[path] for path in test_paths} + train_dataset = DetectionDataset( classes=self.classes, - images={name: self.images[name] for name in train_names}, - annotations={name: self.annotations[name] for name in train_names}, + images=train_input, + annotations=train_annotations, ) test_dataset = DetectionDataset( classes=self.classes, - images={name: self.images[name] for name in test_names}, - annotations={name: self.annotations[name] for name in test_names}, + images=test_input, + annotations=test_annotations, ) return train_dataset, test_dataset + @classmethod + def merge(cls, dataset_list: List[DetectionDataset]) -> DetectionDataset: + """ + Merge a list of `DetectionDataset` objects into a single + `DetectionDataset` object. + + This method takes a list of `DetectionDataset` objects and combines + their respective fields (`classes`, `images`, + `annotations`) into a single `DetectionDataset` object. + + Args: + dataset_list (List[DetectionDataset]): A list of `DetectionDataset` + objects to merge. + + Returns: + (DetectionDataset): A single `DetectionDataset` object containing + the merged data from the input list. + + Examples: + ```python + import supervision as sv + + ds_1 = sv.DetectionDataset(...) + len(ds_1) + # 100 + ds_1.classes + # ['dog', 'person'] + + ds_2 = sv.DetectionDataset(...) + len(ds_2) + # 200 + ds_2.classes + # ['cat'] + + ds_merged = sv.DetectionDataset.merge([ds_1, ds_2]) + len(ds_merged) + # 300 + ds_merged.classes + # ['cat', 'dog', 'person'] + ``` + """ + + def is_in_memory(dataset: DetectionDataset) -> bool: + return len(dataset._images_in_memory) > 0 or len(dataset.image_paths) == 0 + + def is_lazy(dataset: DetectionDataset) -> bool: + return not is_in_memory(dataset) or len(dataset._images_in_memory) == 0 + + all_in_memory = all([is_in_memory(dataset) for dataset in dataset_list]) + all_lazy = all([is_lazy(dataset) for dataset in dataset_list]) + if not all_in_memory and not all_lazy: + raise ValueError( + "Merging lazy and in-memory DetectionDatasets is not supported." + ) + + images_in_memory = {} + for dataset in dataset_list: + images_in_memory.update(dataset._images_in_memory) + + image_paths: List[str] = [] + if not images_in_memory: + image_paths = list( + chain.from_iterable(dataset.image_paths for dataset in dataset_list) + ) + image_paths_unique = list(dict.fromkeys(image_paths)) + if len(image_paths) != len(image_paths_unique): + duplicates = find_duplicates(image_paths) + raise ValueError( + f"Image paths {duplicates} are not unique across datasets." + ) + image_paths = image_paths_unique + + classes = merge_class_lists( + class_lists=[dataset.classes for dataset in dataset_list] + ) + + annotations = {} + for dataset in dataset_list: + annotations.update(dataset.annotations) + for dataset in dataset_list: + class_index_mapping = build_class_index_mapping( + source_classes=dataset.classes, target_classes=classes + ) + for image_path in dataset.image_paths: + annotations[image_path] = map_detections_class_id( + source_to_target_mapping=class_index_mapping, + detections=annotations[image_path], + ) + + return cls( + classes=classes, + images=images_in_memory or image_paths, + annotations=annotations, + ) + def as_pascal_voc( self, images_directory_path: Optional[str] = None, @@ -179,23 +347,19 @@ class DetectionDataset(BaseDataset): in the range [0, 1). Argument is used only for segmentation datasets. """ if images_directory_path: - save_dataset_images( - images_directory_path=images_directory_path, images=self.images + self._save_images( + images_directory_path=images_directory_path, ) if annotations_directory_path: Path(annotations_directory_path).mkdir(parents=True, exist_ok=True) - - for image_path, image in self.images.items(): - detections = self.annotations[image_path] - - if annotations_directory_path: + for image_path, image, annotations in self.images.items(): annotation_name = Path(image_path).stem annotations_path = os.path.join( annotations_directory_path, f"{annotation_name}.xml" ) image_name = Path(image_path).name pascal_voc_xml = detections_to_pascal_voc( - detections=detections, + detections=annotations, classes=self.classes, filename=image_name, image_shape=image.shape, @@ -358,9 +522,7 @@ class DetectionDataset(BaseDataset): Argument is used only for segmentation datasets. """ if images_directory_path is not None: - save_dataset_images( - images_directory_path=images_directory_path, images=self.images - ) + self._save_images(images_directory_path=images_directory_path) if annotations_directory_path is not None: save_yolo_annotations( annotations_directory_path=annotations_directory_path, @@ -482,70 +644,11 @@ class DetectionDataset(BaseDataset): approximation_percentage=approximation_percentage, ) - @classmethod - def merge(cls, dataset_list: List[DetectionDataset]) -> DetectionDataset: - """ - Merge a list of `DetectionDataset` objects into a single - `DetectionDataset` object. - - This method takes a list of `DetectionDataset` objects and combines - their respective fields (`classes`, `images`, - `annotations`) into a single `DetectionDataset` object. - - Args: - dataset_list (List[DetectionDataset]): A list of `DetectionDataset` - objects to merge. - - Returns: - (DetectionDataset): A single `DetectionDataset` object containing - the merged data from the input list. - - Examples: - ```python - import supervision as sv - - ds_1 = sv.DetectionDataset(...) - len(ds_1) - # 100 - ds_1.classes - # ['dog', 'person'] - - ds_2 = sv.DetectionDataset(...) - len(ds_2) - # 200 - ds_2.classes - # ['cat'] - - ds_merged = sv.DetectionDataset.merge([ds_1, ds_2]) - len(ds_merged) - # 300 - ds_merged.classes - # ['cat', 'dog', 'person'] - ``` - """ - merged_images, merged_annotations = {}, {} - class_lists = [dataset.classes for dataset in dataset_list] - merged_classes = merge_class_lists(class_lists=class_lists) - - for dataset in dataset_list: - class_index_mapping = build_class_index_mapping( - source_classes=dataset.classes, target_classes=merged_classes - ) - for image_name, image, detections in dataset: - if image_name in merged_annotations: - raise ValueError( - f"Image name {image_name} is not unique across datasets." - ) - - merged_images[image_name] = image - merged_annotations[image_name] = map_detections_class_id( - source_to_target_mapping=class_index_mapping, - detections=detections, - ) - - return cls( - classes=merged_classes, images=merged_images, annotations=merged_annotations - ) + def _save_images(self, images_directory_path: str) -> None: + Path(images_directory_path).mkdir(parents=True, exist_ok=True) + for image_path, image, _ in self: + final_path = os.path.join(images_directory_path, image_path) + cv2.imwrite(final_path, image) @dataclass diff --git a/supervision/dataset/formats/pascal_voc.py b/supervision/dataset/formats/pascal_voc.py index ed18d288..1d962989 100644 --- a/supervision/dataset/formats/pascal_voc.py +++ b/supervision/dataset/formats/pascal_voc.py @@ -138,7 +138,7 @@ def load_pascal_voc_annotations( images_directory_path: str, annotations_directory_path: str, force_masks: bool = False, -) -> Tuple[List[str], Dict[str, np.ndarray], Dict[str, Detections]]: +) -> Tuple[List[str], List[str], Dict[str, Detections]]: """ Loads PASCAL VOC XML annotations and returns the image name, a Detections instance, and a list of class names. @@ -151,44 +151,40 @@ def load_pascal_voc_annotations( annotations, regardless of whether they are present. Returns: - 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. + Tuple[List[str], List[str], Dict[str, Detections]]: A tuple with a list + of class names, a list of paths to images, and a dictionary with image + paths as keys and corresponding Detections instances as values. """ - image_paths = list_files_with_extensions( - directory=images_directory_path, extensions=["jpg", "jpeg", "png"] - ) + image_paths = [ + str(path) + for path in list_files_with_extensions( + directory=images_directory_path, extensions=["jpg", "jpeg", "png"] + ) + ] - classes = [] - images = {} + classes: List[str] = [] annotations = {} for image_path in image_paths: image_name = Path(image_path).stem - image_path = str(image_path) - image = cv2.imread(image_path) annotation_path = os.path.join(annotations_directory_path, f"{image_name}.xml") if not os.path.exists(annotation_path): - images[image_path] = image annotations[image_path] = Detections.empty() continue tree = parse(annotation_path) root = tree.getroot() + image = cv2.imread(image_path) resolution_wh = (image.shape[1], image.shape[0]) annotation, classes = detections_from_xml_obj( root, classes, resolution_wh, force_masks ) - - images[image_path] = image annotations[image_path] = annotation - return classes, images, annotations + return classes, image_paths, annotations def detections_from_xml_obj( diff --git a/supervision/dataset/utils.py b/supervision/dataset/utils.py index 32ece6bf..ef59050d 100644 --- a/supervision/dataset/utils.py +++ b/supervision/dataset/utils.py @@ -1,10 +1,7 @@ import copy -import os import random -from pathlib import Path from typing import Dict, List, Optional, Tuple, TypeVar, Union -import cv2 import numpy as np import numpy.typing as npt @@ -59,6 +56,7 @@ def merge_class_lists(class_lists: List[List[str]]) -> List[str]: def build_class_index_mapping( source_classes: List[str], target_classes: List[str] ) -> Dict[int, int]: + """Returns the index map of source classes -> target classes.""" index_mapping = {} for i, class_name in enumerate(source_classes): @@ -93,17 +91,6 @@ def map_detections_class_id( return detections_copy -def save_dataset_images( - images_directory_path: str, images: Dict[str, np.ndarray] -) -> None: - Path(images_directory_path).mkdir(parents=True, exist_ok=True) - - for image_path, image in images.items(): - image_name = Path(image_path).name - target_image_path = os.path.join(images_directory_path, image_name) - cv2.imwrite(target_image_path, image) - - def train_test_split( data: List[T], train_ratio: float = 0.8, diff --git a/supervision/utils/internal.py b/supervision/utils/internal.py index 072c03b7..4a09e0f7 100644 --- a/supervision/utils/internal.py +++ b/supervision/utils/internal.py @@ -31,6 +31,16 @@ else: warnings.simplefilter("always", SupervisionWarnings) +def warn_deprecated(message: str): + """ + Issue a warning that a function is deprecated. + + Args: + message (str): The message to display when the function is called. + """ + warnings.warn(message, category=SupervisionWarnings, stacklevel=2) + + def deprecated_parameter( old_parameter: str, new_parameter: str, @@ -82,15 +92,13 @@ def deprecated_parameter( else: function_name = func.__name__ - warnings.warn( + warn_deprecated( message=warning_message.format( function_name=function_name, old_parameter=old_parameter, new_parameter=new_parameter, **message_kwargs, - ), - category=SupervisionWarnings, - stacklevel=2, + ) ) kwargs[new_parameter] = map_function(kwargs.pop(old_parameter)) @@ -106,11 +114,7 @@ def deprecated(reason: str): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): - warnings.warn( - f"{func.__name__} is deprecated: {reason}", - category=SupervisionWarnings, - stacklevel=2, - ) + warn_deprecated(f"{func.__name__} is deprecated: {reason}") return func(*args, **kwargs) return wrapper diff --git a/supervision/utils/iterables.py b/supervision/utils/iterables.py index 52bfbeb6..29601d62 100644 --- a/supervision/utils/iterables.py +++ b/supervision/utils/iterables.py @@ -68,3 +68,17 @@ def fill(sequence: List[V], desired_size: int, content: V) -> List[V]: missing_size = max(0, desired_size - len(sequence)) sequence.extend([content] * missing_size) return sequence + + +def find_duplicates(sequence: List) -> List: + """ + Find all duplicate elements in the input sequence. + """ + seen = set() + duplicates = set() + for element in sequence: + if element in seen: + duplicates.add(element) + else: + seen.add(element) + return list(duplicates)