Repository: voxel51/fiftyone

Issue:
[BUG] Cannot provide ./relative_paths in YOLOv5 format
### System information

-  Linux Ubuntu 20.04
-   FiftyOne installed from pip
-   FiftyOne v0.15.1, Voxel51, Inc.
-   Python 3.8.10

### Commands to reproduce

`fiftyone convert --input-dir '/home/me/Desktop/Datasets/MAV-VID' --input-type fiftyone.types.YOLOv5Dataset --output-dir '/home/me/Desktop/Datasets/MAV-VID-COCO' --output-type fiftyone.types.COCODetectionDataset
`

### Describe the problem

I have a YOLOv5 Dataset structured like this:
MAV-VID
   - images
   - labels
   - train.txt
   - test.txt
   - dataset.yaml

where train.txt and test.txt look like this:
./images/45_00002069.jpg
./images/8_00000277.jpg
./images/8_00001444.jpg
..., i.e. containing RELATIVE paths to the images.

Now, when running the above command to convert this YOLOv5 Dataset to another format (COCO in this case), fiftyone crashes while throwing the following error:

Uncaught exception
Traceback (most recent call last):
  File "/home/me/.local/bin/fiftyone", line 8, in <module>
    sys.exit(main())
  File "/home/me/.local/lib/python3.8/site-packages/fiftyone/core/cli.py", line 3031, in main
    args.execute(args)
  File "/home/me/.local/lib/python3.8/site-packages/fiftyone/core/cli.py", line 3014, in <lambda>
    parser.set_defaults(execute=lambda args: command.execute(parser, args))
  File "/home/me/.local/lib/python3.8/site-packages/fiftyone/core/cli.py", line 370, in execute
    foud.convert_dataset(
  File "/home/me/.local/lib/python3.8/site-packages/fiftyone/utils/data/converters.py", line 112, in convert_dataset
    dataset.add_importer(dataset_importer, label_field=label_field)
  File "/home/me/.local/lib/python3.8/site-packages/fiftyone/core/dataset.py", line 3076, in add_importer
    return foud.import_samples(
  File "/home/me/.local/lib/python3.8/site-packages/fiftyone/utils/data/importers.py", line 118, in import_samples
    with dataset_importer:
  File "/home/me/.local/lib/python3.8/site-packages/fiftyone/utils/data/importers.py", line 743, in __enter__
    self.setup()
  File "/home/me/.local/lib/python3.8/site-packages/fiftyone/utils/yolo.py", line 512, in setup
    labels_path = _get_yolo_v5_labels_path(image_path)
  File "/home/me/.local/lib/python3.8/site-packages/fiftyone/utils/yolo.py", line 1070, in _get_yolo_v5_labels_path
    raise ValueError(
ValueError: Invalid image path 'images/12_00000538.jpg'. YOLOv5 image paths must contain '/images/', which is replaced with '/labels/' to locate the corresponding labels


However, when I change the filepaths in the train.txt and test.txt to ABSOLUTE paths, i.e. /home/user/Datasets/..../MAV-VID/images/..., fiftyone converts them just fine.
I suppose this is not the intended behavior?

### Code to reproduce issue

Provide a reproducible test case that is the bare minimum necessary to generate
the problem.

### What areas of FiftyOne does this bug affect?

-   [ ] `App`: FiftyOne application issue
-   [X] `Core`: Core `fiftyone` Python library issue
-   [ ] `Server`: Fiftyone server issue

### Willingness to contribute

The FiftyOne Community encourages bug fix contributions. Would you or another
member of your organization be willing to contribute a fix for this bug to the
FiftyOne codebase?

-   [ ] Yes. I can contribute a fix for this bug independently.
-   [ ] Yes. I would be willing to contribute a fix for this bug with guidance
        from the FiftyOne community.
-   [X] No. I cannot contribute a bug fix at this time.



Discussion:

Resolving patch:
diff --git a/fiftyone/utils/yolo.py b/fiftyone/utils/yolo.py
--- a/fiftyone/utils/yolo.py
+++ b/fiftyone/utils/yolo.py
@@ -490,8 +490,11 @@ def setup(self):
         classes = d.get("names", None)
 
         if etau.is_str(data) and data.endswith(".txt"):
-            txt_path = _parse_yolo_v5_data_path(data, self.yaml_path)
-            image_paths = [fou.normpath(p) for p in _read_file_lines(txt_path)]
+            txt_path = _parse_yolo_v5_path(data, self.yaml_path)
+            image_paths = [
+                _parse_yolo_v5_path(fou.normpath(p), txt_path)
+                for p in _read_file_lines(txt_path)
+            ]
         else:
             if etau.is_str(data):
                 data_dirs = [data]
@@ -501,7 +504,7 @@ def setup(self):
             image_paths = []
             for data_dir in data_dirs:
                 data_dir = fou.normpath(
-                    _parse_yolo_v5_data_path(data_dir, self.yaml_path)
+                    _parse_yolo_v5_path(data_dir, self.yaml_path)
                 )
                 image_paths.extend(
                     etau.list_files(data_dir, abs_paths=True, recursive=True)
@@ -950,7 +953,7 @@ def close(self, *args):
         else:
             classes = self.classes
 
-        d[self.split] = _make_yolo_v5_data_path(self.data_path, self.yaml_path)
+        d[self.split] = _make_yolo_v5_path(self.data_path, self.yaml_path)
         d["nc"] = len(classes)
         d["names"] = list(classes)
 
@@ -1042,23 +1045,23 @@ def load_yolo_annotations(txt_path, classes):
     return fol.Detections(detections=detections)
 
 
-def _parse_yolo_v5_data_path(data_path, yaml_path):
-    if os.path.isabs(data_path):
-        return data_path
+def _parse_yolo_v5_path(filepath, yaml_path):
+    if os.path.isabs(filepath):
+        return filepath
 
     # Interpret relative to YAML file
     root_dir = os.path.dirname(yaml_path)
-    return os.path.normpath(os.path.join(root_dir, data_path))
+    return os.path.normpath(os.path.join(root_dir, filepath))
 
 
-def _make_yolo_v5_data_path(data_path, yaml_path):
+def _make_yolo_v5_path(filepath, yaml_path):
     # Save path relative to YAML file
     root_dir = os.path.dirname(yaml_path)
-    data_path = os.path.relpath(data_path, root_dir) + os.path.sep
-    if not data_path.startswith("."):
-        data_path = "." + os.path.sep + data_path
+    filepath = os.path.relpath(filepath, root_dir) + os.path.sep
+    if not filepath.startswith("."):
+        filepath = "." + os.path.sep + filepath
 
-    return data_path
+    return filepath
 
 
 def _get_yolo_v5_labels_path(image_path):
@@ -1066,14 +1069,19 @@ def _get_yolo_v5_labels_path(image_path):
     new = os.path.sep + "labels" + os.path.sep
 
     chunks = image_path.rsplit(old, 1)
-    if len(chunks) == 1:
+
+    if len(chunks) > 1:
+        labels_path = new.join(chunks)
+    elif image_path.startswith("images" + os.path.sep):
+        labels_path = "labels" + image_path[len("images") :]
+    else:
         raise ValueError(
             "Invalid image path '%s'. YOLOv5 image paths must contain '%s', "
             "which is replaced with '%s' to locate the corresponding labels"
             % (image_path, old, new)
         )
 
-    root, ext = os.path.splitext(new.join(chunks))
+    root, ext = os.path.splitext(labels_path)
 
     if ext:
         ext = ".txt"


Test patch: