From e612d1bfb76a3c3d3d545187338c841a246619fb Mon Sep 17 00:00:00 2001 From: Dmitrii Lavrukhin Date: Fri, 4 Oct 2024 16:20:42 +0400 Subject: [PATCH] YOLOv8 classification (#59) * yolov8 classification * changelog entry * support export/import without media * coderabbitai suggestion * small changes * renaming a method according to what it does * method for extracting image name from path * using relative paths * fixing exporting * changing custom extension to save initial ids * update docs * do not create empty folders for labels when save_media=false * using {} instead of OrderedDict * black fixes * fixing sonarcloud issues * removing subfolders on export * removed a link --- CHANGELOG.md | 2 + datumaro/plugins/yolo_format/converter.py | 92 +++++- datumaro/plugins/yolo_format/extractor.py | 268 ++++++++++++------ datumaro/plugins/yolo_format/format.py | 5 + datumaro/plugins/yolo_format/importer.py | 14 + .../en/docs/formats/yolo_v8_classification.md | 106 +++++++ .../en/docs/user-manual/supported_formats.md | 5 +- .../yolov8_classification/train/label_0/1.jpg | Bin 0 -> 631 bytes .../yolov8_classification/train/label_0/2.jpg | Bin 0 -> 631 bytes .../yolov8_classification/train/label_1/3.jpg | Bin 0 -> 631 bytes tests/unit/data_formats/test_yolo_format.py | 257 ++++++++++++++++- 11 files changed, 659 insertions(+), 90 deletions(-) create mode 100644 site/content/en/docs/formats/yolo_v8_classification.md create mode 100644 tests/assets/yolo_dataset/yolov8_classification/train/label_0/1.jpg create mode 100644 tests/assets/yolo_dataset/yolov8_classification/train/label_0/2.jpg create mode 100644 tests/assets/yolo_dataset/yolov8_classification/train/label_1/3.jpg diff --git a/CHANGELOG.md b/CHANGELOG.md index f42f1c712e..6471d49669 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 () - Support for YOLOv8 formats () +- Support for YOLOv8 Classification format + () ### Changed - `env.detect_dataset()` now returns a list of detected formats at all recursion levels diff --git a/datumaro/plugins/yolo_format/converter.py b/datumaro/plugins/yolo_format/converter.py index 5c5f6e7417..ab24980e5e 100644 --- a/datumaro/plugins/yolo_format/converter.py +++ b/datumaro/plugins/yolo_format/converter.py @@ -3,11 +3,12 @@ # # SPDX-License-Identifier: MIT +import itertools import logging as log import math import os import os.path as osp -from collections import OrderedDict +from collections import OrderedDict, defaultdict from functools import cached_property from itertools import cycle from typing import Dict, List, Optional @@ -28,9 +29,10 @@ from datumaro.components.errors import DatasetExportError, MediaTypeError from datumaro.components.extractor import DEFAULT_SUBSET_NAME, DatasetItem, IExtractor from datumaro.components.media import Image -from datumaro.util import str_to_bool +from datumaro.util import dump_json_file, str_to_bool +from datumaro.util.os_util import split_path -from .format import YoloPath, YOLOv8Path +from .format import YoloPath, YOLOv8ClassificationFormat, YOLOv8Path def _make_yolo_bbox(img_size, box): @@ -399,3 +401,87 @@ def _make_annotation_line(self, width: int, height: int, skeleton: Annotation) - points_values[position] = f"{x:.6f} {y:.6f} {element.visibility[0].value}" return f"{self._map_labels_for_save[skeleton.label]} {bbox_string_values} {' '.join(points_values)}\n" + + +class YOLOv8ClassificationConverter(Converter): + DEFAULT_IMAGE_EXT = ".jpg" + + def apply(self): + save_dir = self._save_dir + + if self._extractor.media_type() and not issubclass(self._extractor.media_type(), Image): + raise MediaTypeError("Media type is not an image") + + os.makedirs(save_dir, exist_ok=True) + self._used_paths = defaultdict(set) + + if self._save_dataset_meta: + self._save_meta_file(self._save_dir) + + labels = self._extractor.categories()[AnnotationType.label] + + subsets = self._extractor.subsets() + pbars = self._ctx.progress_reporter.split(len(subsets)) + for (subset_name, subset), pbar in zip(subsets.items(), pbars): + if not subset_name or subset_name == DEFAULT_SUBSET_NAME: + subset_name = YoloPath.DEFAULT_SUBSET_NAME + + os.makedirs(osp.join(self._save_dir, subset_name), exist_ok=True) + + items_info = defaultdict(dict) + + for item in pbar.iter(subset, desc=f"Exporting '{subset_name}'"): + try: + items_info[item.id]["labels"] = [ + labels[anno.label].name + for anno in item.annotations + if anno.type == AnnotationType.label + ] + for label_name in items_info[item.id]["labels"] or [ + YOLOv8ClassificationFormat.IMAGE_DIR_NO_LABEL + ]: + items_info[item.id]["path"] = self._export_media_for_label( + item, subset_name, label_name + ) + + except Exception as e: + self._ctx.error_policy.report_item_error(e, item_id=(item.id, item.subset)) + + labels_path = osp.join( + self._save_dir, + subset_name, + YOLOv8ClassificationFormat.LABELS_FILE, + ) + dump_json_file(labels_path, items_info) + + def _generate_path_for_item(self, item: DatasetItem, label_folder_path: str) -> str: + item_name = base_item_name = split_path(item.id)[-1] + if item_name in self._used_paths[label_folder_path]: + for index in itertools.count(): + item_name = f"{base_item_name}.{index}" + if item_name not in self._used_paths[label_folder_path]: + break + self._used_paths[label_folder_path].add(item_name) + return self._make_image_filename(item, name=item_name, subdir=label_folder_path) + + def _export_media_for_label(self, item: DatasetItem, subset_name: str, label_name: str) -> str: + try: + if not item.media or not (item.media.has_data or item.media.has_size): + raise DatasetExportError( + "Failed to export item '%s': " "item has no image info" % item.id + ) + subset_path = osp.join(self._save_dir, subset_name) + label_folder_path = osp.join(subset_path, label_name) + + image_fpath = self._generate_path_for_item(item, label_folder_path) + + if self._save_media: + if item.media: + os.makedirs(label_folder_path, exist_ok=True) + self._save_image(item, image_fpath) + else: + log.warning("Item '%s' has no image" % item.id) + + return osp.relpath(image_fpath, subset_path) + except Exception as e: + self._ctx.error_policy.report_item_error(e, item_id=(item.id, item.subset)) diff --git a/datumaro/plugins/yolo_format/extractor.py b/datumaro/plugins/yolo_format/extractor.py index 83676b1a6c..4cd0188795 100644 --- a/datumaro/plugins/yolo_format/extractor.py +++ b/datumaro/plugins/yolo_format/extractor.py @@ -8,7 +8,6 @@ import os import os.path as osp import re -from collections import OrderedDict from functools import cached_property from itertools import cycle from typing import Any, Dict, List, Optional, Tuple, Type, TypeVar, Union @@ -21,6 +20,7 @@ Annotation, AnnotationType, Bbox, + Label, LabelCategories, Points, PointsCategories, @@ -34,30 +34,29 @@ ) from datumaro.components.extractor import CategoriesInfo, DatasetItem, Extractor, SourceExtractor from datumaro.components.media import Image +from datumaro.util import parse_json_file, take_by from datumaro.util.image import ( DEFAULT_IMAGE_META_FILE_NAME, ImageMeta, + find_images, load_image, load_image_meta_file, ) from datumaro.util.meta_file_util import get_meta_file, has_meta_file, parse_meta_file from datumaro.util.os_util import split_path -from ...util import parse_json_file, take_by -from .format import YoloPath, YOLOv8Path, YOLOv8PoseFormat +from .format import YoloPath, YOLOv8ClassificationFormat, YOLOv8Path, YOLOv8PoseFormat T = TypeVar("T") -class YoloExtractor(SourceExtractor): - RESERVED_CONFIG_KEYS = YoloPath.RESERVED_CONFIG_KEYS - +class YoloBaseExtractor(SourceExtractor): class Subset(Extractor): - def __init__(self, name: str, parent: YoloExtractor): + def __init__(self, name: str, parent: YoloBaseExtractor): super().__init__() self._name = name self._parent = parent - self.items: Dict[str, Union[str, DatasetItem]] = OrderedDict() + self.items: Dict[str, Union[str, DatasetItem]] = {} def __iter__(self): for item_id in self.items: @@ -73,17 +72,15 @@ def categories(self): def __init__( self, - config_path: str, + rootpath: str, image_info: Union[None, str, ImageMeta] = None, **kwargs, ) -> None: - if not osp.isfile(config_path): - raise DatasetImportError(f"Can't read dataset descriptor file '{config_path}'") + if not osp.isdir(rootpath): + raise DatasetImportError(f"Can't read dataset folder '{rootpath}'") super().__init__(**kwargs) - rootpath = osp.dirname(config_path) - self._config_path = config_path self._path = rootpath assert image_info is None or isinstance(image_info, (str, dict)) @@ -98,6 +95,86 @@ def __init__( self._categories = self._load_categories() + self._subsets: Dict[str, YoloBaseExtractor.Subset] = {} + + for subset_name in self._get_subset_names(): + subset = YoloBaseExtractor.Subset(subset_name, self) + subset.items = self._get_lazy_subset_items(subset_name) + self._subsets[subset_name] = subset + + @classmethod + def _image_loader(cls, *args, **kwargs): + return load_image(*args, **kwargs, keep_exif=True) + + def _get(self, item_id: str, subset_name: str) -> Optional[DatasetItem]: + subset = self._subsets[subset_name] + item = subset.items[item_id] + + if isinstance(item, str): + try: + image_size = self._image_info.get(item_id) + image_path = osp.join(self._path, item) + + if image_size: + image = Image(path=image_path, size=image_size) + else: + image = Image(path=image_path, data=self._image_loader) + + annotations = self._parse_annotations(image, item_id=(item_id, subset_name)) + + item = DatasetItem( + id=item_id, subset=subset_name, media=image, annotations=annotations + ) + subset.items[item_id] = item + except (FileNotFoundError, IOError, DatasetImportError) as e: + self._ctx.error_policy.report_item_error(e, item_id=(item_id, subset_name)) + subset.items.pop(item_id) + item = None + + return item + + def _get_subset_names(self): + raise NotImplementedError() + + def _get_lazy_subset_items(self, subset_name: str): + raise NotImplementedError() + + def _parse_annotations(self, image: Image, *, item_id: Tuple[str, str]) -> List[Annotation]: + raise NotImplementedError() + + def _load_categories(self) -> CategoriesInfo: + raise NotImplementedError() + + def __iter__(self): + subsets = self._subsets + pbars = self._ctx.progress_reporter.split(len(subsets)) + for pbar, (subset_name, subset) in zip(pbars, subsets.items()): + for item in pbar.iter(subset, desc=f"Parsing '{subset_name}'"): + yield item + + def __len__(self): + return sum(len(s) for s in self._subsets.values()) + + def get_subset(self, name): + return self._subsets[name] + + +class YoloExtractor(YoloBaseExtractor): + RESERVED_CONFIG_KEYS = YoloPath.RESERVED_CONFIG_KEYS + + def __init__( + self, + config_path: str, + image_info: Union[None, str, ImageMeta] = None, + **kwargs, + ) -> None: + if not osp.isfile(config_path): + raise DatasetImportError(f"Can't read dataset descriptor file '{config_path}'") + + self._config_path = config_path + super().__init__(rootpath=osp.dirname(config_path), image_info=image_info, **kwargs) + + def _get_subset_names(self): # The original format is like this: # # classes = 2 @@ -108,20 +185,16 @@ def __init__( # # To support more subset names, we disallow subsets # called 'classes', 'names' and 'backup'. - subsets = {k: v for k, v in self._config.items() if k not in self.RESERVED_CONFIG_KEYS} + return [k for k in self._config if k not in self.RESERVED_CONFIG_KEYS] - for subset_name, list_path in subsets.items(): - subset = YoloExtractor.Subset(subset_name, self) - subset.items = OrderedDict( - (self.name_from_path(p), self.localize_path(p)) - for p in self._iterate_over_image_paths(subset_name, list_path) - ) - subsets[subset_name] = subset - - self._subsets: Dict[str, YoloExtractor.Subset] = subsets + def _get_lazy_subset_items(self, subset_name: str): + return { + self.name_from_path(p): self.localize_path(p) + for p in self._get_subset_image_paths(subset_name) + } - def _iterate_over_image_paths(self, subset_name: str, list_path: str): - list_path = osp.join(self._path, self.localize_path(list_path)) + def _get_subset_image_paths(self, subset_name: str): + list_path = osp.join(self._path, self.localize_path(self._config[subset_name])) if not osp.isfile(list_path): raise InvalidAnnotationError(f"Can't find '{subset_name}' subset list file") @@ -175,43 +248,9 @@ def name_from_path(cls, path: str) -> str: return osp.splitext(path)[0] - @classmethod - def _image_loader(cls, *args, **kwargs): - return load_image(*args, **kwargs, keep_exif=True) - def _get_labels_path_from_image_path(self, image_path: str) -> str: return osp.splitext(image_path)[0] + YoloPath.LABELS_EXT - def _get(self, item_id: str, subset_name: str) -> Optional[DatasetItem]: - subset = self._subsets[subset_name] - item = subset.items[item_id] - - if isinstance(item, str): - try: - image_size = self._image_info.get(item_id) - image_path = osp.join(self._path, item) - - if image_size: - image = Image(path=image_path, size=image_size) - else: - image = Image(path=image_path, data=self._image_loader) - - anno_path = self._get_labels_path_from_image_path(image.path) - annotations = self._parse_annotations( - anno_path, image, item_id=(item_id, subset_name) - ) - - item = DatasetItem( - id=item_id, subset=subset_name, media=image, annotations=annotations - ) - subset.items[item_id] = item - except Exception as e: - self._ctx.error_policy.report_item_error(e, item_id=(item_id, subset_name)) - subset.items.pop(item_id) - item = None - - return item - @staticmethod def _parse_field(value: str, cls: Type[T], field_name: str) -> T: try: @@ -221,9 +260,8 @@ def _parse_field(value: str, cls: Type[T], field_name: str) -> T: f"Can't parse {field_name} from '{value}'. Expected {cls}" ) from e - def _parse_annotations( - self, anno_path: str, image: Image, *, item_id: Tuple[str, str] - ) -> List[Annotation]: + def _parse_annotations(self, image: Image, *, item_id: Tuple[str, str]) -> List[Annotation]: + anno_path = self._get_labels_path_from_image_path(image.path) lines = [] with open(anno_path, "r", encoding="utf-8") as f: for line in f: @@ -306,19 +344,6 @@ def _load_categories(self) -> CategoriesInfo: return {AnnotationType.label: label_categories} - def __iter__(self): - subsets = self._subsets - pbars = self._ctx.progress_reporter.split(len(subsets)) - for pbar, (subset_name, subset) in zip(pbars, subsets.items()): - for item in pbar.iter(subset, desc=f"Parsing '{subset_name}'"): - yield item - - def __len__(self): - return sum(len(s) for s in self._subsets.values()) - - def get_subset(self, name): - return self._subsets[name] - class YOLOv8DetectionExtractor(YoloExtractor): RESERVED_CONFIG_KEYS = YOLOv8Path.RESERVED_CONFIG_KEYS @@ -331,12 +356,11 @@ def __init__( ) -> None: super().__init__(*args, **kwargs) - def _parse_annotations( - self, anno_path: str, image: Image, *, item_id: Tuple[str, str] - ) -> List[Annotation]: + def _parse_annotations(self, image: Image, *, item_id: Tuple[str, str]) -> List[Annotation]: + anno_path = self._get_labels_path_from_image_path(image.path) if not osp.exists(anno_path): return [] - return super()._parse_annotations(anno_path, image, item_id=item_id) + return super()._parse_annotations(image, item_id=item_id) @cached_property def _config(self) -> Dict[str, Any]: @@ -412,12 +436,11 @@ def name_from_path(cls, path: str) -> str: path = osp.join(*parts[2:]) # pylint: disable=no-value-for-parameter return osp.splitext(path)[0] - def _iterate_over_image_paths( - self, subset_name: str, subset_images_source: Union[str, List[str]] - ): + def _get_subset_image_paths(self, subset_name: str): + subset_images_source = self._config[subset_name] if isinstance(subset_images_source, str): if subset_images_source.endswith(YoloPath.SUBSET_LIST_EXT): - yield from super()._iterate_over_image_paths(subset_name, subset_images_source) + yield from super()._get_subset_image_paths(subset_name) else: path = osp.join(self._path, self.localize_path(subset_images_source)) if not osp.isdir(path): @@ -654,3 +677,82 @@ def _load_one_annotation( ] ] return Skeleton(points, label=label_id) + + +class YOLOv8ClassificationExtractor(YoloBaseExtractor): + def _get_subset_names(self): + return [ + subset_name + for subset_name in os.listdir(self._path) + if osp.isdir(osp.join(self._path, subset_name)) + ] + + def _get_image_paths_for_subset_and_label(self, subset_name: str, label_name: str) -> list[str]: + category_folder = osp.join(self._path, subset_name, label_name) + image_list_path = osp.join(category_folder, YOLOv8ClassificationFormat.LABELS_FILE) + if osp.isfile(image_list_path): + with open(image_list_path, "r", encoding="utf-8") as f: + yield from (osp.join(subset_name, label_name, line.strip()) for line in f) + + yield from ( + osp.relpath(image_path, self._path) + for image_path in find_images(category_folder, recursive=True) + ) + + def _get_item_info_from_labels_file(self, subset_name: str) -> Optional[Dict]: + subset_path = osp.join(self._path, subset_name) + labels_file_path = osp.join(subset_path, YOLOv8ClassificationFormat.LABELS_FILE) + if osp.isfile(labels_file_path): + return parse_json_file(labels_file_path) + + def _get_lazy_subset_items(self, subset_name: str): + subset_path = osp.join(self._path, subset_name) + + if item_info := self._get_item_info_from_labels_file(subset_name): + return {id: osp.join(subset_name, item_info[id]["path"]) for id in item_info} + + return { + self.name_from_path(image_path): image_path + for category_name in os.listdir(subset_path) + if osp.isdir(osp.join(subset_path, category_name)) + for image_path in self._get_image_paths_for_subset_and_label(subset_name, category_name) + } + + def _parse_annotations(self, image: Image, *, item_id: Tuple[str, str]) -> List[Annotation]: + item_id, subset_name = item_id + if item_info := self._get_item_info_from_labels_file(subset_name): + label_names = item_info[item_id]["labels"] + else: + subset_path = osp.join(self._path, subset_name) + relative_image_path = osp.relpath(image.path, subset_path) + label_names = [split_path(relative_image_path)[0]] + + return [ + Label(label=self._categories[AnnotationType.label].find(label)[0]) + for label in label_names + if label != YOLOv8ClassificationFormat.IMAGE_DIR_NO_LABEL + ] + + def _load_categories(self) -> CategoriesInfo: + categories = set() + for subset in os.listdir(self._path): + subset_path = osp.join(self._path, subset) + if not osp.isdir(subset_path): + continue + + if item_info := self._get_item_info_from_labels_file(subset): + categories.update(*[item_info[item_id]["labels"] for item_id in item_info]) + + for label_dir_name in os.listdir(subset_path): + if not osp.isdir(osp.join(subset_path, label_dir_name)): + continue + if label_dir_name == YOLOv8ClassificationFormat.IMAGE_DIR_NO_LABEL: + continue + categories.add(label_dir_name) + return {AnnotationType.label: LabelCategories.from_iterable(sorted(categories))} + + @classmethod + def name_from_path(cls, path_from_root: str) -> str: + subset_folder = split_path(path_from_root)[0] + path_from_subset_folder = osp.relpath(path_from_root, subset_folder) + return osp.splitext(path_from_subset_folder)[0] diff --git a/datumaro/plugins/yolo_format/format.py b/datumaro/plugins/yolo_format/format.py index a1f7b1563b..a266c5c540 100644 --- a/datumaro/plugins/yolo_format/format.py +++ b/datumaro/plugins/yolo_format/format.py @@ -26,3 +26,8 @@ class YOLOv8Path(YoloPath): class YOLOv8PoseFormat: KPT_SHAPE_FIELD_NAME = "kpt_shape" + + +class YOLOv8ClassificationFormat: + IMAGE_DIR_NO_LABEL = "no_label" + LABELS_FILE = "labels.json" diff --git a/datumaro/plugins/yolo_format/importer.py b/datumaro/plugins/yolo_format/importer.py index eb9f08b2b2..0ba4238b76 100644 --- a/datumaro/plugins/yolo_format/importer.py +++ b/datumaro/plugins/yolo_format/importer.py @@ -5,6 +5,7 @@ from __future__ import annotations +import os from os import path as osp from typing import Any, Dict, List @@ -13,6 +14,7 @@ from datumaro import Importer from datumaro.components.format_detection import FormatDetectionContext from datumaro.plugins.yolo_format.extractor import ( + YOLOv8ClassificationExtractor, YOLOv8DetectionExtractor, YOLOv8OrientedBoxesExtractor, YOLOv8PoseExtractor, @@ -107,3 +109,15 @@ def _check_config_file(cls, context, config_file): raise Exception except yaml.YAMLError: raise Exception + + +class YOLOv8ClassificationImporter(Importer): + @classmethod + def find_sources(cls, path): + if not osp.isdir(path): + return [] + if not [ + subfolder for name in os.listdir(path) if osp.isdir(subfolder := osp.join(path, name)) + ]: + return [] + return [{"url": path, "format": YOLOv8ClassificationExtractor.NAME}] diff --git a/site/content/en/docs/formats/yolo_v8_classification.md b/site/content/en/docs/formats/yolo_v8_classification.md new file mode 100644 index 0000000000..6dc3a3f20f --- /dev/null +++ b/site/content/en/docs/formats/yolo_v8_classification.md @@ -0,0 +1,106 @@ +--- +title: 'YOLOv8Classification' +linkTitle: 'YOLOv8Classification' +description: '' +--- + +## Format specification +Dataset format specification can be found +[here](https://docs.ultralytics.com/datasets/classify/) + +Supported types of annotations: +- `Label` + +Format doesn't support any attributes for annotations objects. + + +## Import YOLOv8 classification dataset + +A Datumaro project with a ImageNet dataset can be created +in the following way: + +``` +datum create +datum import -f yolov8_classification +``` + +Load YOLOOv8 Classification dataset through the Python API: + +```python +import datumaro as dm + +dataset = dm.Dataset.import_from('', format='yolov8_classification') +``` + +For successful importing of YOLOv8 Classification dataset the input directory with dataset +should has the following structure: + +```bash +dataset/ +├── train +│ ├── labels.json # optional datumaro extension. Contains original ids and labels +│ ├── label_0 +│ │ ├── .jpg +│ │ ├── .jpg +│ │ ├── .jpg +│ │ ├── ... +│ ├── label_1 +│ │ ├── .jpg +│ │ ├── .jpg +│ │ ├── .jpg +│ │ ├── ... +├── ... +``` + +## Export YOLOv8 Classification dataset + +Datumaro can convert the dataset into any other format +[Datumaro supports](/docs/user-manual/supported_formats). +To get the expected result, convert the dataset to a format +that supports `Label` annotation objects. + +``` +# Using `convert` command +datum convert -if yolov8_classification -i \ + -f voc -o -- --save-media + +# Using Datumaro project +datum create +datum import -f yolov8_classification +datum export -f open_images -o +``` + +And also you can convert your YOLOv8 Classification dataset using Python API + +```python +import datumaro as dm + +dataset = dm.Dataset.import_from('', format='vgg_face2', save_media=True) +``` + +> Note: some formats have extra export options. For particular format see the +> [docs](/docs/formats/) to get information about it. + +## Export dataset to the YOLOv8 Classification format + +If your dataset contains `Label` for images and you want to convert this +dataset into the YOLOv8 Classification format, you can use Datumaro for it: + +``` +# Using convert command +datum convert -if open_images -i \ + -f yolov8_classification -o -- --save-media --save-dataset-meta + +# Using Datumaro project +datum create +datum import -f open_images +datum export -f yolov8_classification -o +``` + +Extra options for exporting to YOLOv8 Classification formats: +- `--save-media` allow to export dataset with saving media files + (by default `False`) +- `--save-dataset-meta` - allow to export dataset with saving dataset meta + file (by default `False`) diff --git a/site/content/en/docs/user-manual/supported_formats.md b/site/content/en/docs/user-manual/supported_formats.md index 794c4a5e2f..ab0e7ab3a9 100644 --- a/site/content/en/docs/user-manual/supported_formats.md +++ b/site/content/en/docs/user-manual/supported_formats.md @@ -178,7 +178,10 @@ List of supported formats: - [Format specification](https://docs.ultralytics.com/datasets/obb/) - [Dataset example](https://docs.ultralytics.com/datasets/obb/dota8/) - [Format documentation](/docs/formats/yolo_v8) - + - Classification + - [Format specification](https://docs.ultralytics.com/datasets/classify/) + - [Dataset example](https://github.com/cvat-ai/datumaro/tree/develop/tests/assets/yolo_dataset/yolov8_classification) + - [Format documentation](/docs/formats/yolo_v8_classification) ### Supported annotation types diff --git a/tests/assets/yolo_dataset/yolov8_classification/train/label_0/1.jpg b/tests/assets/yolo_dataset/yolov8_classification/train/label_0/1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8689b956311969f2efc9e3334f375c0ad65e24f1 GIT binary patch literal 631 zcmex=^(PF6}rMnOeST|r4lSw=>~TvNxu(8R<c1}I=;VrF4wW9Q)H;sz?% zD!{d!pzFb!U9xX3zTPI5o8roG<0MW4oqZMDikqloVbuf*=gfJ(V&YTRE(2~ znmD<{#3dx9RMpfqG__1j&CD$#!v`*nMGf}^(PF6}rMnOeST|r4lSw=>~TvNxu(8R<c1}I=;VrF4wW9Q)H;sz?% zD!{d!pzFb!U9xX3zTPI5o8roG<0MW4oqZMDikqloVbuf*=gfJ(V&YTRE(2~ znmD<{#3dx9RMpfqG__1j&CD$#!v`*nMGf}^(PF6}rMnOeST|r4lSw=>~TvNxu(8R<c1}I=;VrF4wW9Q)H;sz?% zD!{d!pzFb!U9xX3zTPI5o8roG<0MW4oqZMDikqloVbuf*=gfJ(V&YTRE(2~ znmD<{#3dx9RMpfqG__1j&CD$#!v`*nMGf}