Repository: voxel51/fiftyone
Pull request: Adding support for customizing dataset imports and exports
Resolves #707, and deprecates #1050.

Adds a variety of new syntaxes for importing and exporting datasets that allow, among other things, independent control of the data and labels locations for many standard formats via new `data_path`, `labels_path`, and `export_media` parameters (plus additional parameters on a per-dataset basis).

The docs from `SampleCollection.export()` describe how these parameters function:

```
            export_dir (None): the directory to which to export the samples in
                format ``dataset_type``. This parameter may be omitted if you
                have provided appropriate values for the ``data_path`` and/or
                ``labels_path`` parameters.

            data_path (None): an optional parameter that enables explicit
                control over the location of the exported media for certain
                export formats. Can be any of the following:

                -   a folder name like "data" or "data/" specifying a subfolder
                    of ``export_dir`` in which to export the media
                -   an absolute directory path in which to export the media. In
                    this case, the ``export_dir`` has no effect on the location
                    of the data
                -   a filename like "data.json" specifying the filename of a
                    JSON manifest file in ``export_dir`` generated when
                    ``export_media`` is ``"manifest"``
                -   an absolute filepath specifying the location to write the
                    JSON manifest file when ``export_media`` is ``"manifest"``.
                    In this case, ``export_dir`` has no effect on the location
                    of the data

                If None, a default value of this parameter will be chosen based
                on the value of the ``export_media`` parameter. Note that this
                parameter is not applicable to certain export formats such as
                binary types like TF records

            labels_path (None): an optional parameter that enables explicit
                control over the location of the exported labels. Only
                applicable when exporting in certain labeled dataset formats.
                Can be any of the following:

                -   a type-specific folder name like "labels" or "labels/" or a
                    filename like "labels.json" or "labels.xml" specifying the
                    location in ``export_dir`` in which to export the labels
                -   an absolute directory or filepath in which to export the
                    labels. In this case, the ``export_dir`` has no effect on
                    the location of the labels

                For labeled datasets, the default value of this parameter will
                be chosen based on the export format so that the labels will be
                exported into ``export_dir``

            export_media (None): controls how to export the raw media. The
                supported values are:

                -   ``True``: copy all media files into the output directory
                -   ``False``: don't export media. This option is only useful
                    when exporting labeled datasets whose label format stores
                    sufficient information to locate the associated media
                -   ``"move"``: move all media files into the output directory
                -   ``"symlink"``: create symlinks to the media files in the
                    output directory
                -   ``"manifest"``: create a ``data.json`` in the output
                    directory that maps UUIDs used in the labels files to the
                    filepaths of the source media, rather than exporting the
                    actual media

                If None, an appropriate default value of this parameter will be
                chosen based on the value of the ``data_path`` parameter. Note
                that some dataset formats may not support certain values for
                this parameter (e.g., when exporting in binary formats such as
                TF records, "symlink" is not an option)
```

This is a lot of information, but I have endeavored to make the usage as intuitive as possible. For example:
- If you provide `export_dir`, you'll get a data + labels export with default filenames/labels names (current behavior)
- If you provide `labels_path` + `export_media=True/"move"/"symlink"`, then you'll get a data + labels export 
- If you provide `labels_path` + `data_path`, you'll get a data + labels export where the media are copied (IE `export_media=True` is chosen by default)
- If you only provide `labels_path`, then you'll get a labels-only export (IE `export_media` defaults to `False`)
- If you specify `export_media="manifest"`, then `data_path` will automatically default to `data.json` rather than `data/`
- If you provide a JSON filepath for `data_path`, then `export_media` will default to `"manifest"` 

### Other changes

- Adds new `merge_dir()` and `merge_importer()` functions that combine `from_dir() + merge_samples()` into a single method call.
- Renamed `YOLODataset` to `YOLOv4Dataset`, and added a new `YOLOv5Dataset` type for importing/exporting in [YOLOv5 format](https://github.com/ultralytics/yolov5)
- Updated `GeoJSONImageDataset` to `GeoJSONDataset` to reflect, both in name and function, that this type can handle both image and video datasets

### TODO
- Update loading datasets user guide page
- Add import/export unit tests

### Examples

The example below demonstrates labels-only imports and exports using the new syntax:

```py
import os

import fiftyone as fo
import fiftyone.zoo as foz

#
# Part 1: Labels-only exports
#

dataset = foz.load_zoo_dataset("quickstart")

# In this workflow, no images are ever copied/moved. Everything points here
IMAGES_DIR = os.path.dirname(dataset.first().filepath)

GT_PATH = "/tmp/coco/ground_truth.json"
PREDICTIONS_PATH = "/tmp/coco/predictions.json"

# Export ground truth labels
dataset.export(
    dataset_type=fo.types.COCODetectionDataset,
    labels_path=GT_PATH,
    label_field="ground_truth",
)

# Export predictions
dataset.export(
    dataset_type=fo.types.COCODetectionDataset,
    labels_path=PREDICTIONS_PATH,
    label_field="predictions",
)

#
# Part 2: Labels-only imports
#

# Load ground truth
dataset2 = fo.Dataset.from_dir(
    dataset_type=fo.types.COCODetectionDataset,
    data_path=IMAGES_DIR,
    labels_path=GT_PATH,
    label_field="ground_truth",
)

# Add predictions to dataset
dataset2.merge_dir(
    dataset_type=fo.types.COCODetectionDataset,
    data_path=IMAGES_DIR,
    labels_path=PREDICTIONS_PATH,
    label_field="predictions",
)

print(dataset2)
print(dataset2.count("ground_truth.detections"))
print(dataset2.count("predictions.detections"))
```


Problem statement:
[FR] Flag to avoid copying data when exporting Dataset labels
I updated labels for a dataset in FiftyOne and want to use them in my own scripts to train a new model. The images are the same and I don't want to have to copy 118K images that I can just link back to them using the new labels. 

It's easy enough to take an `Exporter` and remove the step where files are copies but it would be nice to have a flag for copying data or just exporting labels. This would also be nice to have in the `fiftyone convert` command.

Hints from issue discussion:
yep makes sense, I like it.

Commit message: improving label_field for zoo datasets

Patch to review:
diff --git a/docs/source/integrations/lightning_flash.rst b/docs/source/integrations/lightning_flash.rst
index 7c83cd8e56c..f87fe3b64de 100644
--- a/docs/source/integrations/lightning_flash.rst
+++ b/docs/source/integrations/lightning_flash.rst
@@ -39,6 +39,30 @@ In order to use the Lightning Flash integration, you'll need to
 
     pip install lightning-flash
 
+Depending on the type of Flash tasks that you intend to use, you will also need
+to install some package extras:
+
+.. code-block:: shell
+
+    # Required to use image tasks
+    pip install 'lightning-flash[image]'
+
+    # Required to use video tasks
+    pip install 'lightning-flash[video]'
+
+You can always proceed without these initially, as you'll be prompted to
+install the appropriate extras when you use a feature that requires them.
+
+.. note::
+
+    Flash video tasks require Python 3.7 or later, due to their dependence on
+    `pytorchvideo <https://github.com/facebookresearch/pytorchvideo>`_.
+
+    In addition, it is currently recommended that you use Python 3.7 or later
+    for image tasks as well, as you may
+    `encounter issues <https://github.com/PyTorchLightning/lightning-flash/issues/451>`_
+    trying to use Flash image tasks on Python 3.6.
+
 .. _flash-model-training:
 
 Model training
diff --git a/docs/source/release-notes.rst b/docs/source/release-notes.rst
index 613bb816f67..df54252b82e 100644
--- a/docs/source/release-notes.rst
+++ b/docs/source/release-notes.rst
@@ -310,7 +310,7 @@ Core
       an interacive scatterplot of a dataset via its |GeoLocation| coordinates
     - Added |GeoLocation| and |GeoLocations| label types that can be used to store
       arbitrary GeoJSON location data on samples
-    - Added the :class:`GeoJSONImageDataset <fiftyone.types.dataset_types.GeoJSONImageDataset>`
+    - Added the :class:`GeoJSONDataset <fiftyone.types.dataset_types.GeoJSONDataset>`
       dataset type for importing and exporting datasets in GeoJSON format
     - Added :meth:`SampleCollection.geo_near() <fiftyone.core.collections.SampleCollection.geo_near>`
       and
@@ -1031,9 +1031,9 @@ Core
   :class:`segmentation masks <fiftyone.core.labels.Segmentation>` on samples
 - Added support for setting an `index` attribute on |Detection| instances that
   defines a unique identifier for an object (e.g., across frames of a video)
-- Added support for :ref:`importing <YOLODataset-import>` and
-  :ref:`exporting <YOLODataset-export>` datasets in
-  `YOLO format <https://github.com/AlexeyAB/darknet>`_
+- Added support for :ref:`importing <YOLOv4Dataset-import>` and
+  :ref:`exporting <YOLOv4Dataset-export>` datasets in
+  `YOLOv4 format <https://github.com/AlexeyAB/darknet>`_
 - Added support for :ref:`importing <CVATVideoDataset-import>` and
   :ref:`exporting <CVATVideoDataset-export>` datasets in
   `CVAT video format <https://github.com/openvinotoolkit/cvat/blob/develop/cvat/apps/documentation/xml_format.md>`_
diff --git a/docs/source/user_guide/dataset_creation/datasets.rst b/docs/source/user_guide/dataset_creation/datasets.rst
index 2945efb09a7..eefa793bc34 100644
--- a/docs/source/user_guide/dataset_creation/datasets.rst
+++ b/docs/source/user_guide/dataset_creation/datasets.rst
@@ -35,7 +35,7 @@ that you're loading.
         import fiftyone as fo
 
         # A name for the dataset
-        name = "my-coco-dataset"
+        name = "my-dataset"
 
         # The directory containing the dataset to import
         dataset_dir = "/path/to/dataset"
@@ -70,7 +70,7 @@ that you're loading.
     .. code-block:: shell
 
         # A name for the dataset
-        NAME=my-coco-dataset
+        NAME=my-dataset
 
         # The directory containing the dataset to import
         DATASET_DIR=/path/to/dataset
@@ -135,8 +135,11 @@ format when reading the dataset from disk.
     | :ref:`KITTIDetectionDataset <KITTIDetectionDataset-import>`                           | A labeled dataset consisting of images and their associated object detections      |
     |                                                                                       | saved in `KITTI format <http://www.cvlibs.net/datasets/kitti/eval\_object.php>`_.  |
     +---------------------------------------------------------------------------------------+------------------------------------------------------------------------------------+
-    | :ref:`YOLODataset <YOLODataset-import>`                                               | A labeled dataset consisting of images and their associated object detections      |
-    |                                                                                       | saved in `YOLO format <https://github.com/AlexeyAB/darknet>`_.                     |
+    | :ref:`YOLOv4Dataset <YOLOv4Dataset-import>`                                           | A labeled dataset consisting of images and their associated object detections      |
+    |                                                                                       | saved in `YOLOv4 format <https://github.com/AlexeyAB/darknet>`_.                   |
+    +---------------------------------------------------------------------------------------+------------------------------------------------------------------------------------+
+    | :ref:`YOLOv5Dataset <YOLOv5Dataset-import>`                                           | A labeled dataset consisting of images and their associated object detections      |
+    |                                                                                       | saved in `YOLOv5 format <https://github.com/ultralytics/yolov5>`_.                 |
     +---------------------------------------------------------------------------------------+------------------------------------------------------------------------------------+
     | :ref:`TFObjectDetectionDataset <TFObjectDetectionDataset-import>`                     | A labeled dataset consisting of images and their associated object detections      |
     |                                                                                       | stored as TFRecords in `TF Object Detection API format \                           |
@@ -158,7 +161,7 @@ format when reading the dataset from disk.
     | :ref:`BDDDataset <BDDDataset-import>`                                                 | A labeled dataset consisting of images and their associated multitask predictions  |
     |                                                                                       | saved in `Berkeley DeepDrive (BDD) format <https://bdd-data.berkeley.edu>`_.       |
     +---------------------------------------------------------------------------------------+------------------------------------------------------------------------------------+
-    | :ref:`GeoJSONImageDataset <GeoJSONImageDataset-import>`                               | An image dataset whose labels and location data are stored in                      |
+    | :ref:`GeoJSONDataset <GeoJSONDataset-import>`                                         | An image or video dataset whose location data and labels are stored in             |
     |                                                                                       | `GeoJSON format <https://en.wikipedia.org/wiki/GeoJSON>`_.                         |
     +---------------------------------------------------------------------------------------+------------------------------------------------------------------------------------+
     | :ref:`FiftyOneVideoLabelsDataset <FiftyOneVideoLabelsDataset-import>`                 | A labeled dataset consisting of videos and their associated multitask predictions  |
@@ -202,7 +205,7 @@ You can create a FiftyOne dataset from a directory of images as follows:
 
         import fiftyone as fo
 
-        name = "my-images-dir"
+        name = "my-dataset"
         dataset_dir = "/path/to/images-dir"
 
         # Create the dataset
@@ -218,7 +221,7 @@ You can create a FiftyOne dataset from a directory of images as follows:
 
     .. code:: shell
 
-      NAME=my-images-dir
+      NAME=my-dataset
       DATASET_DIR=/path/to/images-dir
 
       # Create the dataset
@@ -275,7 +278,7 @@ You can create a FiftyOne dataset from a directory of videos as follows:
 
         import fiftyone as fo
 
-        name = "my-videos-dir"
+        name = "my-dataset"
         dataset_dir = "/path/to/videos-dir"
 
         # Create the dataset
@@ -291,7 +294,7 @@ You can create a FiftyOne dataset from a directory of videos as follows:
 
     .. code:: shell
 
-      NAME=my-videos-dir
+      NAME=my-dataset
       DATASET_DIR=/path/to/videos-dir
 
       # Create the dataset
@@ -373,7 +376,7 @@ in the above format as follows:
 
         import fiftyone as fo
 
-        name = "my-image-classification-dataset"
+        name = "my-dataset"
         dataset_dir = "/path/to/image-classification-dataset"
 
         # Create the dataset
@@ -391,7 +394,7 @@ in the above format as follows:
 
     .. code-block:: shell
 
-        NAME=my-image-classification-dataset
+        NAME=my-dataset
         DATASET_DIR=/path/to/image-classification-dataset
 
         # Create the dataset
@@ -456,7 +459,7 @@ stored in the above format as follows:
 
         import fiftyone as fo
 
-        name = "my-image-classification-dir-tree"
+        name = "my-dataset"
         dataset_dir = "/path/to/image-classification-dir-tree"
 
         # Create the dataset
@@ -474,7 +477,7 @@ stored in the above format as follows:
 
     .. code-block:: shell
 
-        NAME=my-image-classification-dir-tree
+        NAME=my-dataset
         DATASET_DIR=/path/to/image-classification-dir-tree
 
         # Create the dataset
@@ -539,7 +542,7 @@ stored in the above format as follows:
 
         import fiftyone as fo
 
-        name = "my-video-classification-dir-tree"
+        name = "my-dataset"
         dataset_dir = "/path/to/video-classification-dir-tree"
 
         # Create the dataset
@@ -557,7 +560,7 @@ stored in the above format as follows:
 
     .. code-block:: shell
 
-        NAME=my-video-classification-dir-tree
+        NAME=my-dataset
         DATASET_DIR=/path/to/video-classification-dir-tree
 
         # Create the dataset
@@ -635,7 +638,7 @@ as a directory of TFRecords in the above format as follows:
 
         import fiftyone as fo
 
-        name = "my-tf-image-classification-dataset"
+        name = "my-dataset"
         dataset_dir = "/path/to/tf-image-classification-dataset"
         images_dir = "/path/for/images"
 
@@ -661,7 +664,7 @@ as a directory of TFRecords in the above format as follows:
 
     .. code-block:: shell
 
-        NAME=my-tf-image-classification-dataset
+        NAME=my-dataset
         DATASET_DIR=/path/to/tf-image-classification-dataset
         IMAGES_DIR=/path/for/images
 
@@ -768,7 +771,7 @@ above format as follows:
 
         import fiftyone as fo
 
-        name = "my-image-detection-dataset"
+        name = "my-dataset"
         dataset_dir = "/path/to/image-detection-dataset"
 
         # Create the dataset
@@ -786,7 +789,7 @@ above format as follows:
 
     .. code-block:: shell
 
-        NAME=my-image-detection-dataset
+        NAME=my-dataset
         DATASET_DIR=/path/to/image-detection-dataset
 
         # Create the dataset
@@ -900,7 +903,7 @@ above format as follows:
 
         import fiftyone as fo
 
-        name = "my-coco-detection-dataset"
+        name = "my-dataset"
         dataset_dir = "/path/to/coco-detection-dataset"
 
         # Create the dataset
@@ -918,7 +921,7 @@ above format as follows:
 
     .. code-block:: shell
 
-        NAME=my-coco-detection-dataset
+        NAME=my-dataset
         DATASET_DIR=/path/to/coco-detection-dataset
 
         # Create the dataset
@@ -1029,7 +1032,7 @@ above format as follows:
 
         import fiftyone as fo
 
-        name = "my-voc-detection-dataset"
+        name = "my-dataset"
         dataset_dir = "/path/to/voc-detection-dataset"
 
         # Create the dataset
@@ -1047,7 +1050,7 @@ above format as follows:
 
     .. code-block:: shell
 
-        NAME=my-voc-detection-dataset
+        NAME=my-dataset
         DATASET_DIR=/path/to/voc-detection-dataset
 
         # Create the dataset
@@ -1150,7 +1153,7 @@ above format as follows:
 
         import fiftyone as fo
 
-        name = "my-kitti-detection-dataset"
+        name = "my-dataset"
         dataset_dir = "/path/to/kitti-detection-dataset"
 
         # Create the dataset
@@ -1168,7 +1171,7 @@ above format as follows:
 
     .. code-block:: shell
 
-        NAME=my-kitti-detection-dataset
+        NAME=my-dataset
         DATASET_DIR=/path/to/kitti-detection-dataset
 
         # Create the dataset
@@ -1196,15 +1199,15 @@ above format as follows:
             --dataset-dir $DATASET_DIR \
             --type fiftyone.types.KITTIDetectionDataset
 
-.. _YOLODataset-import:
+.. _YOLOv4Dataset-import:
 
-YOLODataset
------------
+YOLOv4Dataset
+-------------
 
-The :class:`fiftyone.types.YOLODataset <fiftyone.types.dataset_types.YOLODataset>`
+The :class:`fiftyone.types.YOLOv4Dataset <fiftyone.types.dataset_types.YOLOv4Dataset>`
 type represents a labeled dataset consisting of images and their associated
 object detections saved in
-`YOLO format <https://github.com/AlexeyAB/darknet>`_.
+`YOLOv4 format <https://github.com/AlexeyAB/darknet>`_.
 
 Datasets of this type are read in the following format:
 
@@ -1250,7 +1253,126 @@ relative coordinates in `[0, 1] x [0, 1]`.
 
 Unlabeled images have no corresponding TXT file in `data/`.
 
-You can create a FiftyOne dataset from a YOLO dataset stored in the above
+You can create a FiftyOne dataset from a YOLOv4 dataset stored in the above
+format as follows:
+
+.. tabs::
+
+  .. group-tab:: Python
+
+    .. code-block:: python
+        :linenos:
+
+        import fiftyone as fo
+
+        name = "my-dataset"
+        dataset_dir = "/path/to/yolov4-dataset"
+
+        # Create the dataset
+        dataset = fo.Dataset.from_dir(
+            dataset_dir, fo.types.YOLOv4Dataset, name=name
+        )
+
+        # View summary info about the dataset
+        print(dataset)
+
+        # Print the first few samples in the dataset
+        print(dataset.head())
+
+  .. group-tab:: CLI
+
+    .. code-block:: shell
+
+        NAME=my-dataset
+        DATASET_DIR=/path/to/yolov4-dataset
+
+        # Create the dataset
+        fiftyone datasets create \
+            --name $NAME \
+            --dataset-dir $DATASET_DIR \
+            --type fiftyone.types.YOLOv4Dataset
+
+        # View summary info about the dataset
+        fiftyone datasets info $NAME
+
+        # Print the first few samples in the dataset
+        fiftyone datasets head $NAME
+
+    To view a YOLOv4 dataset stored in the above format in the FiftyOne App
+    without creating a persistent FiftyOne dataset, you can execute:
+
+    .. code-block:: shell
+
+        DATASET_DIR=/path/to/yolov4-dataset
+
+        # View the dataset in the App
+        fiftyone app view \
+            --dataset-dir $DATASET_DIR \
+            --type fiftyone.types.YOLOv4Dataset
+
+.. _YOLOv5Dataset-import:
+
+YOLOv5Dataset
+-------------
+
+The :class:`fiftyone.types.YOLOv5Dataset <fiftyone.types.dataset_types.YOLOv5Dataset>`
+type represents a labeled dataset consisting of images and their associated
+object detections saved in
+`YOLOv5 format <https://github.com/ultralytics/yolov5>`_.
+
+Datasets of this type are read in the following format:
+
+.. code-block:: text
+
+    <dataset_dir>/
+        dataset.yaml
+        images/
+            train/
+                <uuid1>.<ext>
+                <uuid2>.<ext>
+                ...
+            val/
+                <uuid3>.<ext>
+                <uuid4>.<ext>
+                ...
+        labels/
+            train/
+                <uuid1>.txt
+                <uuid2>.txt
+                ...
+            val/
+                <uuid3>.txt
+                <uuid4>.txt
+                ...
+
+where `dataset.yaml` contains the following information:
+
+.. code-block:: text
+
+    train: ./images/train/
+    val: ./images/val/
+
+    # number of classes
+    nc: 80
+
+    # class names
+    names: ["list", "of", "classes", ...]
+
+and the TXT files in `labels/` are space-delimited files where each row
+corresponds to an object in the image of the same name, in the following
+format:
+
+.. code-block:: text
+
+    <target> <x-center> <y-center> <width> <height>
+
+where `<target>` is the zero-based integer index of the object class label from
+`names` and the bounding box coordinates are expressed as
+relative coordinates in `[0, 1] x [0, 1]`.
+
+Unlabeled images have no corresponding TXT file in `labels/`.
+
+You can create a FiftyOne dataset from a YOLOv5 dataset stored in the above
 format as follows:
 
 .. tabs::
@@ -1262,12 +1384,12 @@ format as follows:
 
         import fiftyone as fo
 
-        name = "my-yolo-dataset"
-        dataset_dir = "/path/to/yolo-dataset"
+        name = "my-dataset"
+        dataset_dir = "/path/to/yolov5-dataset"
 
         # Create the dataset
         dataset = fo.Dataset.from_dir(
-            dataset_dir, fo.types.YOLODataset, name=name
+            dataset_dir, fo.types.YOLOv5Dataset, name=name
         )
 
         # View summary info about the dataset
@@ -1280,14 +1402,14 @@ format as follows:
 
     .. code-block:: shell
 
-        NAME=my-yolo-dataset
-        DATASET_DIR=/path/to/yolo-dataset
+        NAME=my-dataset
+        DATASET_DIR=/path/to/yolov5-dataset
 
         # Create the dataset
         fiftyone datasets create \
             --name $NAME \
             --dataset-dir $DATASET_DIR \
-            --type fiftyone.types.YOLODataset
+            --type fiftyone.types.YOLOv5Dataset
 
         # View summary info about the dataset
         fiftyone datasets info $NAME
@@ -1295,17 +1417,17 @@ format as follows:
         # Print the first few samples in the dataset
         fiftyone datasets head $NAME
 
-    To view a YOLO dataset stored in the above format in the FiftyOne App
+    To view a YOLOv5 dataset stored in the above format in the FiftyOne App
     without creating a persistent FiftyOne dataset, you can execute:
 
     .. code-block:: shell
 
-        DATASET_DIR=/path/to/yolo-dataset
+        DATASET_DIR=/path/to/yolov5-dataset
 
         # View the dataset in the App
         fiftyone app view \
             --dataset-dir $DATASET_DIR \
-            --type fiftyone.types.YOLODataset
+            --type fiftyone.types.YOLOv5Dataset
 
 .. _TFObjectDetectionDataset-import:
 
@@ -1378,7 +1500,7 @@ directory of TFRecords in the above format as follows:
 
         import fiftyone as fo
 
-        name = "my-tf-object-detection-dataset"
+        name = "my-dataset"
         dataset_dir = "/path/to/tf-object-detection-dataset"
         images_dir = "/path/for/images"
 
@@ -1404,7 +1526,7 @@ directory of TFRecords in the above format as follows:
 
     .. code-block:: shell
 
-        NAME=my-tf-object-detection-dataset
+        NAME=my-dataset
         DATASET_DIR=/path/to/tf-object-detection-dataset
         IMAGES_DIR=/path/for/images
 
@@ -1477,7 +1599,7 @@ the above format as follows:
 
         import fiftyone as fo
 
-        name = "my-image-segmentation-dataset"
+        name = "my-dataset"
         dataset_dir = "/path/to/image-segmentation-dataset"
 
         # Create the dataset
@@ -1495,7 +1617,7 @@ the above format as follows:
 
     .. code-block:: shell
 
-        NAME=my-image-segmentation-dataset
+        NAME=my-dataset
         DATASET_DIR=/path/to/image-segmentation-dataset
 
         # Create the dataset
@@ -1606,7 +1728,7 @@ format as follows:
 
         import fiftyone as fo
 
-        name = "my-cvat-image-dataset"
+        name = "my-dataset"
         dataset_dir = "/path/to/cvat-image-dataset"
 
         # Create the dataset
@@ -1624,7 +1746,7 @@ format as follows:
 
     .. code-block:: shell
 
-        NAME=my-cvat-image-dataset
+        NAME=my-dataset
         DATASET_DIR=/path/to/cvat-image-dataset
 
         # Create the dataset
@@ -1680,77 +1802,77 @@ where the labels XML files are stored in the following format:
 .. code-block:: xml
 
     <?xml version="1.0" encoding="utf-8"?>
-        <annotations>
-            <version>1.1</version>
-            <meta>
-                <task>
-                    <id>task-id</id>
-                    <name>task-name</name>
-                    <size>51</size>
-                    <mode>interpolation</mode>
-                    <overlap></overlap>
-                    <bugtracker></bugtracker>
-                    <flipped>False</flipped>
-                    <created>2017-11-20 11:51:51.000000+00:00</created>
-                    <updated>2017-11-20 11:51:51.000000+00:00</updated>
-                    <labels>
-                        <label>
-                            <name>car</name>
-                            <attributes>
-                                <attribute>
-                                    <name>type</name>
-                                    <values>coupe\\nsedan\\ntruck</values>
-                                </attribute>
-                                ...
-                            </attributes>
-                        </label>
-                        <label>
-                            <name>person</name>
-                            <attributes>
-                                <attribute>
-                                    <name>gender</name>
-                                    <values>male\\nfemale</values>
-                                </attribute>
-                                ...
-                            </attributes>
-                        </label>
-                        ...
-                    </labels>
-                </task>
-                <segments>
-                    <segment>
-                        <id>0</id>
-                        <start>0</start>
-                        <stop>50</stop>
-                        <url></url>
-                    </segment>
-                </segments>
-                <owner>
-                    <username></username>
-                    <email></email>
-                </owner>
-                <original_size>
-                    <width>640</width>
-                    <height>480</height>
-                </original_size>
-                <dumped>2017-11-20 11:51:51.000000+00:00</dumped>
-            </meta>
-            <track id="0" label="car">
-                <box frame="0" xtl="100" ytl="50" xbr="325" ybr="190" outside="0" occluded="0" keyframe="1">
-                    <attribute name="type">sedan</attribute>
+    <annotations>
+        <version>1.1</version>
+        <meta>
+            <task>
+                <id>task-id</id>
+                <name>task-name</name>
+                <size>51</size>
+                <mode>interpolation</mode>
+                <overlap></overlap>
+                <bugtracker></bugtracker>
+                <flipped>False</flipped>
+                <created>2017-11-20 11:51:51.000000+00:00</created>
+                <updated>2017-11-20 11:51:51.000000+00:00</updated>
+                <labels>
+                    <label>
+                        <name>car</name>
+                        <attributes>
+                            <attribute>
+                                <name>type</name>
+                                <values>coupe\\nsedan\\ntruck</values>
+                            </attribute>
+                            ...
+                        </attributes>
+                    </label>
+                    <label>
+                        <name>person</name>
+                        <attributes>
+                            <attribute>
+                                <name>gender</name>
+                                <values>male\\nfemale</values>
+                            </attribute>
+                            ...
+                        </attributes>
+                    </label>
                     ...
-                </box>
+                </labels>
+            </task>
+            <segments>
+                <segment>
+                    <id>0</id>
+                    <start>0</start>
+                    <stop>50</stop>
+                    <url></url>
+                </segment>
+            </segments>
+            <owner>
+                <username></username>
+                <email></email>
+            </owner>
+            <original_size>
+                <width>640</width>
+                <height>480</height>
+            </original_size>
+            <dumped>2017-11-20 11:51:51.000000+00:00</dumped>
+        </meta>
+        <track id="0" label="car">
+            <box frame="0" xtl="100" ytl="50" xbr="325" ybr="190" outside="0" occluded="0" keyframe="1">
+                <attribute name="type">sedan</attribute>
                 ...
-            </track>
+            </box>
             ...
-            <track id="10" label="person">
-                <box frame="45" xtl="300" ytl="25" xbr="375" ybr="400" outside="0" occluded="0" keyframe="1">
-                    <attribute name="gender">female</attribute>
-                    ...
-                </box>
+        </track>
+        ...
+        <track id="10" label="person">
+            <box frame="45" xtl="300" ytl="25" xbr="375" ybr="400" outside="0" occluded="0" keyframe="1">
+                <attribute name="gender">female</attribute>
                 ...
-            </track>
-        </annotations>
+            </box>
+            ...
+        </track>
+    </annotations>
 
 Unlabeled videos have no corresponding file in `labels/`.
 
@@ -1766,7 +1888,7 @@ format as follows:
 
         import fiftyone as fo
 
-        name = "my-cvat-video-dataset"
+        name = "my-dataset"
         dataset_dir = "/path/to/cvat-video-dataset"
 
         # Create the dataset
@@ -1784,7 +1906,7 @@ format as follows:
 
     .. code-block:: shell
 
-        NAME=my-cvat-video-dataset
+        NAME=my-dataset
         DATASET_DIR=/path/to/cvat-video-dataset
 
         # Create the dataset
@@ -1873,7 +1995,7 @@ above format as follows:
 
         import fiftyone as fo
 
-        name = "my-image-labels-dataset"
+        name = "my-dataset"
         dataset_dir = "/path/to/image-labels-dataset"
 
         # Create the dataset
@@ -1891,7 +2013,7 @@ above format as follows:
 
     .. code-block:: shell
 
-        NAME=my-image-labels-dataset
+        NAME=my-dataset
         DATASET_DIR=/path/to/image-labels-dataset
 
         # Create the dataset
@@ -1980,7 +2102,7 @@ above format as follows:
 
         import fiftyone as fo
 
-        name = "my-video-labels-dataset"
+        name = "my-dataset"
         dataset_dir = "/path/to/video-labels-dataset"
 
         # Create the dataset
@@ -1998,7 +2120,7 @@ above format as follows:
 
     .. code-block:: shell
 
-        NAME=my-video-labels-dataset
+        NAME=my-dataset
         DATASET_DIR=/path/to/video-labels-dataset
 
         # Create the dataset
@@ -2140,7 +2262,7 @@ as follows:
 
         import fiftyone as fo
 
-        name = "my-bdd-dataset"
+        name = "my-dataset"
         dataset_dir = "/path/to/bdd-dataset"
 
         # Create the dataset
@@ -2156,7 +2278,7 @@ as follows:
 
     .. code-block:: shell
 
-        NAME=my-bdd-dataset
+        NAME=my-dataset
         DATASET_DIR=/path/to/bdd-dataset
 
         # Create the dataset
@@ -2183,13 +2305,13 @@ as follows:
             --dataset-dir $DATASET_DIR \
             --type fiftyone.types.BDDDataset
 
-.. _GeoJSONImageDataset-import:
+.. _GeoJSONDataset-import:
 
-GeoJSONImageDataset
--------------------
+GeoJSONDataset
+--------------
 
-The :class:`fiftyone.types.GeoJSONImageDataset <fiftyone.types.dataset_types.GeoJSONImageDataset>`
-type represents a dataset consisting of images and their associated
+The :class:`fiftyone.types.GeoJSONDataset <fiftyone.types.dataset_types.GeoJSONDataset>`
+type represents a dataset consisting of images or videos and their associated
 geolocation data and optional properties stored in
 `GeoJSON format <https://en.wikipedia.org/wiki/GeoJSON>`_.
 
@@ -2245,20 +2367,17 @@ the following format:
     }
 
 where the ``geometry`` field may contain any valid GeoJSON geometry object, and
-the ``filename`` property encodes the name of the corresponding image in the
-``data/`` folder.
+the ``filename`` property encodes the name of the corresponding media in the
+``data/`` folder. The ``filename`` property can also be an absolute path, which
+may or may not be in the ``data/`` folder.
 
-You can also specify a ``filepath`` property rather than ``filename``, in which
-case the path is interpreted as an absolute path to the corresponding image,
-which may or may not be in ``data/`` folder.
-
-Images with no location data will have a null ``geometry`` field.
+Samples with no location data will have a null ``geometry`` field.
 
 The ``properties`` field of each feature can contain additional labels that
 can be imported when working with datasets of this type.
 
-You can create a FiftyOne dataset from a GeoJSON image dataset stored in the
-above format as follows:
+You can create a FiftyOne dataset from a GeoJSON dataset stored in the above
+format as follows:
 
 .. tabs::
 
@@ -2269,12 +2388,12 @@ above format as follows:
 
         import fiftyone as fo
 
-        name = "my-geojson-image-dataset"
-        dataset_dir = "/path/to/geojson-image-dataset"
+        name = "my-dataset"
+        dataset_dir = "/path/to/geojson-dataset"
 
         # Create the dataset
         dataset = fo.Dataset.from_dir(
-            dataset_dir, fo.types.GeoJSONImageDataset, name=name
+            dataset_dir, fo.types.GeoJSONDataset, name=name
         )
 
         # View summary info about the dataset
@@ -2287,14 +2406,14 @@ above format as follows:
 
     .. code-block:: shell
 
-        NAME=my-geojson-image-dataset
-        DATASET_DIR=/path/to/geojson-image-dataset
+        NAME=my-dataset
+        DATASET_DIR=/path/to/geojson-dataset
 
         # Create the dataset
         fiftyone datasets create \
             --name $NAME \
             --dataset-dir $DATASET_DIR \
-            --type fiftyone.types.GeoJSONImageDataset
+            --type fiftyone.types.GeoJSONDataset
 
         # View summary info about the dataset
         fiftyone datasets info $NAME
@@ -2302,17 +2421,17 @@ above format as follows:
         # Print the first few samples in the dataset
         fiftyone datasets head $NAME
 
-    To view a GeoJSON image dataset stored in the above format in the FiftyOne
-    App without creating a persistent FiftyOne dataset, you can execute:
+    To view a GeoJSON dataset stored in the above format in the FiftyOne App
+    without creating a persistent FiftyOne dataset, you can execute:
 
     .. code-block:: shell
 
-        DATASET_DIR=/path/to/geojson-image-dataset
+        DATASET_DIR=/path/to/geojson-dataset
 
         # View the dataset in the App
         fiftyone app view \
             --dataset-dir $DATASET_DIR \
-            --type fiftyone.types.GeoJSONImageDataset
+            --type fiftyone.types.GeoJSONDataset
 
 .. _FiftyOneDataset-import:
 
@@ -2364,7 +2483,7 @@ follows:
 
         import fiftyone as fo
 
-        name = "my-fiftyone-dataset"
+        name = "my-dataset"
         dataset_dir = "/path/to/fiftyone-dataset"
 
         # Create the dataset
@@ -2380,7 +2499,7 @@ follows:
 
     .. code-block:: shell
 
-        NAME=my-fiftyone-dataset
+        NAME=my-dataset
         DATASET_DIR=/path/to/fiftyone-dataset
 
         # Create the dataset
diff --git a/docs/source/user_guide/dataset_creation/index.rst b/docs/source/user_guide/dataset_creation/index.rst
index 943ddf92461..855e43e7e8b 100644
--- a/docs/source/user_guide/dataset_creation/index.rst
+++ b/docs/source/user_guide/dataset_creation/index.rst
@@ -43,14 +43,17 @@ below to figure out which option is best for you.
         - :ref:`FiftyOneImageDetectionDataset <FiftyOneImageDetectionDataset-import>`
         - :ref:`COCODetectionDataset <COCODetectionDataset-import>`
         - :ref:`VOCDetectionDataset <VOCDetectionDataset-import>`
-        - :ref:`YOLODataset <YOLODataset-import>`
+        - :ref:`YOLOv4Dataset <YOLOv4Dataset-import>`
+        - :ref:`YOLOv5Dataset <YOLOv5Dataset-import>`
         - :ref:`KITTIDetectionDataset <KITTIDetectionDataset-import>`
         - :ref:`TFObjectDetectionDataset <TFObjectDetectionDataset-import>`
+        - :ref:`ImageSegmentationDirectory <ImageSegmentationDirectory-import>`
         - :ref:`CVATImageDataset <CVATImageDataset-import>`
         - :ref:`CVATVideoDataset <CVATVideoDataset-import>`
         - :ref:`FiftyOneImageLabelsDataset <FiftyOneImageLabelsDataset-import>`
         - :ref:`FiftyOneVideoLabelsDataset <FiftyOneVideoLabelsDataset-import>`
         - :ref:`BDDDataset <BDDDataset-import>`
+        - :ref:`GeoJSONDataset <GeoJSONDataset-import>`
         - :ref:`FiftyOneDataset <FiftyOneDataset-import>`
 
         If one of these formats matches your data, you can load it with the
diff --git a/docs/source/user_guide/export_datasets.rst b/docs/source/user_guide/export_datasets.rst
index 88b9025d569..2b407b613d8 100644
--- a/docs/source/user_guide/export_datasets.rst
+++ b/docs/source/user_guide/export_datasets.rst
@@ -47,13 +47,50 @@ a |DatasetView| into any format of your choice via the basic recipe below.
 
         # Export the dataset!
         dataset_or_view.export(
-            export_dir=export_dir, dataset_type=dataset_type, label_field=label_field
+            export_dir=export_dir,
+            dataset_type=dataset_type,
+            label_field=label_field,
         )
 
     Note the `label_field` argument in the above example, which specifies the
-    particular label field that you wish to export. This is necessary your
+    particular label field that you wish to export. This is necessary if your
     FiftyOne dataset contains multiple label fields.
 
+    The :meth:`export() <fiftyone.core.collections.SampleCollection.export>`
+    method also provides additional parameters that you can use to configure
+    the export, such as the `export_media` parameter, which allows you to
+    configure whether to copy, move, symlink, or omit the media files from
+    the export:
+
+    .. code-block:: python
+        :linenos:
+
+        #
+        # Variation #1
+        #
+        # Export the labels in the `ground_truth` field in COCO format, and
+        # write a `data.json` file describing the locations of the source media
+        # rather than exporting the actual media
+        #
+        dataset_or_view.export(
+            export_dir="/path/for/export",
+            dataset_type=fo.types.COCODetectionDataset,
+            label_field="ground_truth",
+            export_media=False,
+        )
+
+        #
+        # Variation #2
+        #
+        # Directly export **only** labels in the `ground_truth` field in COCO
+        # format
+        #
+        dataset_or_view.export(
+            labels_path="/path/for/export.json",
+            dataset_type=fo.types.COCODetectionDataset,
+            label_field="ground_truth",
+        )
+
   .. group-tab:: CLI
 
     You can export a FiftyOne dataset :doc:`via the CLI </cli/index>`:
@@ -227,8 +264,11 @@ format when writing the dataset to disk.
     | :ref:`KITTIDetectionDataset <KITTIDetectionDataset-export>`        | A labeled dataset consisting of images and their associated object detections      |
     |                                                                    | saved in `KITTI format <http://www.cvlibs.net/datasets/kitti/eval\_object.php>`_.  |
     +--------------------------------------------------------------------+------------------------------------------------------------------------------------+
-    | :ref:`YOLODataset <YOLODataset-export>`                            | A labeled dataset consisting of images and their associated object detections      |
-    |                                                                    | saved in `YOLO format <https://github.com/AlexeyAB/darknet>`_.                     |
+    | :ref:`YOLOv4Dataset <YOLOv4Dataset-export>`                        | A labeled dataset consisting of images and their associated object detections      |
+    |                                                                    | saved in `YOLOv4 format <https://github.com/AlexeyAB/darknet>`_.                   |
+    +--------------------------------------------------------------------+------------------------------------------------------------------------------------+
+    | :ref:`YOLOv5Dataset <YOLOv5Dataset-export>`                        | A labeled dataset consisting of images and their associated object detections      |
+    |                                                                    | saved in `YOLOv5 format <https://github.com/ultralytics/yolov5>`_.                 |
     +--------------------------------------------------------------------+------------------------------------------------------------------------------------+
     | :ref:`TFObjectDetectionDataset <TFObjectDetectionDataset-export>`  | A labeled dataset consisting of images and their associated object detections      |
     |                                                                    | stored as TFRecords in `TF Object Detection API format \                           |
@@ -254,7 +294,7 @@ format when writing the dataset to disk.
     | :ref:`BDDDataset <BDDDataset-export>`                              | A labeled dataset consisting of images and their associated multitask predictions  |
     |                                                                    | saved in `Berkeley DeepDrive (BDD) format <https://bdd-data.berkeley.edu>`_.       |
     +--------------------------------------------------------------------+------------------------------------------------------------------------------------+
-    | :ref:`GeoJSONImageDataset <GeoJSONImageDataset-export>`            | An image dataset whose labels and location data are stored in                      |
+    | :ref:`GeoJSONDataset <GeoJSONDataset-export>`                      | An image or video dataset whose location data and labels are stored in             |
     |                                                                    | `GeoJSON format <https://en.wikipedia.org/wiki/GeoJSON>`_.                         |
     +--------------------------------------------------------------------+------------------------------------------------------------------------------------+
     | :ref:`FiftyOneDataset <FiftyOneDataset-export>`                    | A dataset consisting of an entire serialized |Dataset| and its associated source   |
@@ -1087,15 +1127,15 @@ format as follows:
             --label-field $LABEL_FIELD \
             --type fiftyone.types.KITTIDetectionDataset
 
-.. _YOLODataset-export:
+.. _YOLOv4Dataset-export:
 
-YOLODataset
------------
+YOLOv4Dataset
+-------------
 
-The :class:`fiftyone.types.YOLODataset <fiftyone.types.dataset_types.YOLODataset>`
+The :class:`fiftyone.types.YOLOv4Dataset <fiftyone.types.dataset_types.YOLOv4Dataset>`
 type represents a labeled dataset consisting of images and their associated
 object detections saved in
-`YOLO format <https://github.com/AlexeyAB/darknet>`_.
+`YOLOv4 format <https://github.com/AlexeyAB/darknet>`_.
 
 Datasets of this type are exported in the following format:
 
@@ -1141,7 +1181,7 @@ relative coordinates in `[0, 1] x [0, 1]`.
 
 Unlabeled images have no corresponding TXT file in `data/`.
 
-You can export a FiftyOne dataset as a YOLO dataset in the above format as
+You can export a FiftyOne dataset as a YOLOv4 dataset in the above format as
 follows:
 
 .. tabs::
@@ -1153,7 +1193,7 @@ follows:
 
         import fiftyone as fo
 
-        export_dir = "/path/for/yolo-dataset"
+        export_dir = "/path/for/yolov4-dataset"
         label_field = "ground_truth"  # for example
 
         # The Dataset or DatasetView to export
@@ -1162,7 +1202,7 @@ follows:
         # Export the dataset
         dataset_or_view.export(
             export_dir=export_dir,
-            dataset_type=fo.types.YOLODataset,
+            dataset_type=fo.types.YOLOv4Dataset,
             label_field=label_field,
         )
 
@@ -1171,14 +1211,115 @@ follows:
     .. code-block:: shell
 
         NAME=my-dataset
-        EXPORT_DIR=/path/for/yolo-dataset
+        EXPORT_DIR=/path/for/yolov4-dataset
         LABEL_FIELD=ground_truth  # for example
 
         # Export the dataset
         fiftyone datasets export $NAME \
             --export-dir $EXPORT_DIR \
             --label-field $LABEL_FIELD \
-            --type fiftyone.types.YOLODataset
+            --type fiftyone.types.YOLOv4Dataset
+
+.. _YOLOv5Dataset-export:
+
+YOLOv5Dataset
+-------------
+
+The :class:`fiftyone.types.YOLOv5Dataset <fiftyone.types.dataset_types.YOLOv5Dataset>`
+type represents a labeled dataset consisting of images and their associated
+object detections saved in
+`YOLOv5 format <https://github.com/ultralytics/yolov5>`_.
+
+Datasets of this type are exported in the following format:
+
+.. code-block:: text
+
+    <dataset_dir>/
+        dataset.yaml
+        images/
+            train/
+                <uuid1>.<ext>
+                <uuid2>.<ext>
+                ...
+            val/
+                <uuid3>.<ext>
+                <uuid4>.<ext>
+                ...
+        labels/
+            train/
+                <uuid1>.txt
+                <uuid2>.txt
+                ...
+            val/
+                <uuid3>.txt
+                <uuid4>.txt
+                ...
+
+where `dataset.yaml` contains the following information:
+
+.. code-block:: text
+
+    train: ./images/train/
+    val: ./images/val/
+
+    # number of classes
+    nc: 80
+
+    # class names
+    names: ["list", "of", "classes", ...]
+
+and the TXT files in `labels/` are space-delimited files where each row
+corresponds to an object in the image of the same name, in the following
+format:
+
+.. code-block:: text
+
+    <target> <x-center> <y-center> <width> <height>
+
+where `<target>` is the zero-based integer index of the object class label from
+`names` and the bounding box coordinates are expressed as
+relative coordinates in `[0, 1] x [0, 1]`.
+
+Unlabeled images have no corresponding TXT file in `labels/`.
+
+You can export a FiftyOne dataset as a YOLOv5 dataset in the above format as
+follows:
+
+.. tabs::
+
+  .. group-tab:: Python
+
+    .. code-block:: python
+        :linenos:
+
+        import fiftyone as fo
+
+        export_dir = "/path/for/yolov5-dataset"
+        label_field = "ground_truth"  # for example
+
+        # The Dataset or DatasetView to export
+        dataset_or_view = fo.Dataset(...)
+
+        # Export the dataset
+        dataset_or_view.export(
+            export_dir=export_dir,
+            dataset_type=fo.types.YOLOv5Dataset,
+            label_field=label_field,
+        )
+
+  .. group-tab:: CLI
+
+    .. code-block:: shell
+
+        NAME=my-dataset
+        EXPORT_DIR=/path/for/yolov5-dataset
+        LABEL_FIELD=ground_truth  # for example
+
+        # Export the dataset
+        fiftyone datasets export $NAME \
+            --export-dir $EXPORT_DIR \
+            --label-field $LABEL_FIELD \
+            --type fiftyone.types.YOLOv5Dataset
 
 .. _TFObjectDetectionDataset-export:
 
@@ -1916,13 +2057,13 @@ follows:
             --label-field $LABEL_FIELD \
             --type fiftyone.types.BDDDataset
 
-.. _GeoJSONImageDataset-export:
+.. _GeoJSONDataset-export:
 
-GeoJSONImageDataset
--------------------
+GeoJSONDataset
+--------------
 
-The :class:`fiftyone.types.GeoJSONImageDataset <fiftyone.types.dataset_types.GeoJSONImageDataset>`
-type represents a dataset consisting of images and their associated
+The :class:`fiftyone.types.GeoJSONDataset <fiftyone.types.dataset_types.GeoJSONDataset>`
+type represents a dataset consisting of images or videos and their associated
 geolocation data and optional properties stored in
 `GeoJSON format <https://en.wikipedia.org/wiki/GeoJSON>`_.
 
@@ -1978,14 +2119,11 @@ the following format:
     }
 
 where the ``geometry`` field may contain any valid GeoJSON geometry object, and
-the ``filename`` property encodes the name of the corresponding image in the
-``data/`` folder.
-
-Alternatively, the ``filepath`` property may be specified rather than
-``filename``, in which case the path is interpreted as an absolute path to the
-corresponding image.
+the ``filename`` property encodes the name of the corresponding media in the
+``data/`` folder. The ``filename`` property can also be an absolute path, which
+may or may not be in the ``data/`` folder.
 
-Images with no location data will have a null ``geometry`` field.
+Samples with no location data will have a null ``geometry`` field.
 
 The ``properties`` field of each feature can contain additional labels for
 each sample.
@@ -2002,7 +2140,7 @@ format as follows:
 
         import fiftyone as fo
 
-        export_dir = "/path/for/geojson-image-dataset"
+        export_dir = "/path/for/geojson-dataset"
 
         # The Dataset or DatasetView to export
         dataset_or_view = fo.Dataset(...)
@@ -2010,7 +2148,7 @@ format as follows:
         # Export the dataset
         dataset_or_view.export(
             export_dir=export_dir,
-            dataset_type=fo.types.GeoJSONImageDataset,
+            dataset_type=fo.types.GeoJSONDataset,
         )
 
   .. group-tab:: CLI
@@ -2018,12 +2156,12 @@ format as follows:
     .. code-block:: shell
 
         NAME=my-dataset
-        EXPORT_DIR=/path/for/geojson-image-dataset
+        EXPORT_DIR=/path/for/geojson-dataset
 
         # Export the dataset
         fiftyone datasets export $NAME \
             --export-dir $EXPORT_DIR \
-            --type fiftyone.types.GeoJSONImageDataset
+            --type fiftyone.types.GeoJSONDataset
 
 .. _FiftyOneDataset-export:
 
diff --git a/fiftyone/core/collections.py b/fiftyone/core/collections.py
index db72b461262..99818a30e3b 100644
--- a/fiftyone/core/collections.py
+++ b/fiftyone/core/collections.py
@@ -1400,11 +1400,11 @@ def compute_patch_embeddings(
             force_square (False): whether to minimally manipulate the patch
                 bounding boxes into squares prior to extraction
             alpha (None): an optional expansion/contraction to apply to the
-                patches before extracting them, in ``[-1, \infty)``. If
-                provided, the length and width of the box are expanded (or
-                contracted, when ``alpha < 0``) by ``(100 * alpha)%``. For
-                example, set ``alpha = 1.1`` to expand the boxes by 10%, and
-                set ``alpha = 0.9`` to contract the boxes by 10%
+                patches before extracting them, in ``[-1, inf)``. If provided,
+                the length and width of the box are expanded (or contracted,
+                when ``alpha < 0``) by ``(100 * alpha)%``. For example, set
+                ``alpha = 1.1`` to expand the boxes by 10%, and set
+                ``alpha = 0.9`` to contract the boxes by 10%
             handle_missing ("skip"): how to handle images with no patches.
                 Supported values are:
 
@@ -5137,6 +5137,9 @@ def export(
         self,
         export_dir=None,
         dataset_type=None,
+        data_path=None,
+        labels_path=None,
+        export_media=None,
         dataset_exporter=None,
         label_field=None,
         label_prefix=None,
@@ -5149,8 +5152,27 @@ def export(
     ):
         """Exports the samples in the collection to disk.
 
-        Provide either ``export_dir`` and ``dataset_type`` or
-        ``dataset_exporter`` to perform an export.
+        You can perform exports with this method via the following basic
+        patterns:
+
+        (a) Provide ``export_dir`` and ``dataset_type`` to export the content
+            to a directory in the default layout for the specified format, as
+            documented in :ref:`this page <exporting-datasets>`
+
+        (b) Provide ``dataset_type`` along with ``data_path``, ``labels_path``,
+            and/or ``export_media`` to directly specify where to export the
+            source media and/or labels (if applicable) in your desired format.
+            This syntax provides the flexibility to, for example, perform
+            workflows like labels-only exports
+
+        (c) Provide a ``dataset_exporter`` to which to feed samples to perform
+            a fully-customized export
+
+        In all workflows, the remaining parameters of this method can be
+        provided to further configure the export.
+
+        See :ref:`this page <exporting-datasets>` for more information about
+        the available export formats and examples of using this method.
 
         See :ref:`this guide <custom-dataset-exporter>` for more details about
         exporting datasets in custom formats by defining your own
@@ -5184,8 +5206,10 @@ def export(
 
         Args:
             export_dir (None): the directory to which to export the samples in
-                format ``dataset_type``. This can also be an archive path with
-                one of the following extensions::
+                format ``dataset_type``. This parameter may be omitted if you
+                have provided appropriate values for the ``data_path`` and/or
+                ``labels_path`` parameters. Alternatively, this can also be an
+                archive path with one of the following extensions::
 
                     .zip, .tar, .tar.gz, .tgz, .tar.bz, .tbz
 
@@ -5195,9 +5219,68 @@ def export(
             dataset_type (None): the
                 :class:`fiftyone.types.dataset_types.Dataset` type to write. If
                 not specified, the default type for ``label_field`` is used
+            data_path (None): an optional parameter that enables explicit
+                control over the location of the exported media for certain
+                export formats. Can be any of the following:
+
+                -   a folder name like ``"data"`` or ``"data/"`` specifying a
+                    subfolder of ``export_dir`` in which to export the media
+                -   an absolute directory path in which to export the media. In
+                    this case, the ``export_dir`` has no effect on the location
+                    of the data
+                -   a filename like ``"data.json"`` specifying the filename of
+                    a JSON manifest file in ``export_dir`` generated when
+                    ``export_media`` is ``"manifest"``
+                -   an absolute filepath specifying the location to write the
+                    JSON manifest file when ``export_media`` is ``"manifest"``.
+                    In this case, ``export_dir`` has no effect on the location
+                    of the data
+
+                If None, a default value of this parameter will be chosen based
+                on the value of the ``export_media`` parameter. Note that this
+                parameter is not applicable to certain export formats such as
+                binary types like TF records
+            labels_path (None): an optional parameter that enables explicit
+                control over the location of the exported labels. Only
+                applicable when exporting in certain labeled dataset formats.
+                Can be any of the following:
+
+                -   a type-specific folder name like ``"labels"`` or
+                    ``"labels/"`` or a filename like ``"labels.json"`` or
+                    ``"labels.xml"`` specifying the location in ``export_dir``
+                    in which to export the labels
+                -   an absolute directory or filepath in which to export the
+                    labels. In this case, the ``export_dir`` has no effect on
+                    the location of the labels
+
+                For labeled datasets, the default value of this parameter will
+                be chosen based on the export format so that the labels will be
+                exported into ``export_dir``
+            export_media (None): controls how to export the raw media. The
+                supported values are:
+
+                -   ``True``: copy all media files into the output directory
+                -   ``False``: don't export media. This option is only useful
+                    when exporting labeled datasets whose label format stores
+                    sufficient information to locate the associated media
+                -   ``"move"``: move all media files into the output directory
+                -   ``"symlink"``: create symlinks to the media files in the
+                    output directory
+                -   ``"manifest"``: create a ``data.json`` in the output
+                    directory that maps UUIDs used in the labels files to the
+                    filepaths of the source media, rather than exporting the
+                    actual media
+
+                If None, an appropriate default value of this parameter will be
+                chosen based on the value of the ``data_path`` parameter. Note
+                that some dataset formats may not support certain values for
+                this parameter (e.g., when exporting in binary formats such as
+                TF records, "symlink" is not an option)
             dataset_exporter (None): a
                 :class:`fiftyone.utils.data.exporters.DatasetExporter` to use
-                to export the samples
+                to export the samples. When provided, parameters such as
+                ``export_dir``, ``dataset_type``, ``data_path``, and
+                ``labels_path`` have no effect
             label_field (None): the name of the label field to export. Only
                 applicable to labeled image datasets or labeled video datasets
                 with sample-level labels. If none of ``label_field``,
@@ -5235,11 +5318,14 @@ def export(
                 dicts to pass to the exporter. Only applicable for labeled
                 video datasets. This parameter can only be used when the
                 exporter can handle dictionaries of frame-level labels
-            overwrite (False): when an ``export_dir`` is provided, whether to
-                delete the existing directory before performing the export
+            overwrite (False): whether to delete existing directories before
+                performing the export (True) or to merge the export with
+                existing files and directories (False). Not applicable when a
+                ``dataset_exporter`` was provided
             **kwargs: optional keyword arguments to pass to the dataset
-                exporter's constructor via
-                ``DatasetExporter(export_dir, **kwargs)``
+                exporter's constructor. If you are exporting image patches,
+                this can also contain keyword arguments for
+                :class:`fiftyone.utils.patches.ImagePatchesExtractor`
         """
         if export_dir is not None and etau.is_archive(export_dir):
             archive_path = export_dir
@@ -5252,38 +5338,21 @@ def export(
                 "Either `dataset_type` or `dataset_exporter` must be provided"
             )
 
-        if dataset_type is not None and inspect.isclass(dataset_type):
-            dataset_type = dataset_type()
-
-        # If no dataset exporter was provided, construct one based on the
-        # dataset type
+        # If no dataset exporter was provided, construct one
         if dataset_exporter is None:
-            if os.path.isdir(export_dir):
-                if overwrite:
-                    etau.delete_dir(export_dir)
-                else:
-                    logger.warning(
-                        "Directory '%s' already exists; export will be merged "
-                        "with existing files",
-                        export_dir,
-                    )
-
-            dataset_exporter_cls = dataset_type.get_dataset_exporter_cls()
+            _handle_existing_dirs(
+                export_dir, data_path, labels_path, overwrite
+            )
 
-            try:
-                dataset_exporter = dataset_exporter_cls(export_dir, **kwargs)
-            except Exception as e:
-                exporter_name = dataset_exporter_cls.__name__
-                raise ValueError(
-                    "Failed to construct exporter using syntax "
-                    "%s(export_dir, **kwargs); you may need to supply "
-                    "mandatory arguments to the constructor via `kwargs`. "
-                    "Please consult the documentation of `%s` to learn more"
-                    % (
-                        exporter_name,
-                        etau.get_class_name(dataset_exporter_cls),
-                    )
-                ) from e
+            dataset_exporter, kwargs = foud.build_dataset_exporter(
+                dataset_type,
+                warn_unused=False,  # don't warn yet, might be patches kwargs
+                export_dir=export_dir,
+                data_path=data_path,
+                labels_path=labels_path,
+                export_media=export_media,
+                **kwargs,
+            )
 
         # Get label field(s) to export
         if isinstance(dataset_exporter, foud.LabeledImageDatasetExporter):
@@ -5332,12 +5401,13 @@ def export(
             label_field_or_dict = label_field
             frame_labels_field_or_dict = frame_labels_field
 
-        # Export the dataset
+        # Perform the export
         foud.export_samples(
             self,
             dataset_exporter=dataset_exporter,
             label_field_or_dict=label_field_or_dict,
             frame_labels_field_or_dict=frame_labels_field_or_dict,
+            **kwargs,
         )
 
         # Archive, if requested
@@ -6599,3 +6669,53 @@ def _get_non_none_value(values):
             return value
 
     return None
+
+
+def _handle_existing_dirs(export_dir, data_path, labels_path, overwrite):
+    if export_dir is not None and os.path.isdir(export_dir):
+        if overwrite:
+            etau.delete_dir(export_dir)
+        else:
+            logger.warning(
+                "Directory '%s' already exists; export will be merged with "
+                "existing files",
+                export_dir,
+            )
+
+    if data_path is not None:
+        if os.path.isabs(data_path) or export_dir is None:
+            _data_path = data_path
+        else:
+            _data_path = os.path.join(export_dir, data_path)
+
+        if os.path.isdir(_data_path):
+            if overwrite:
+                etau.delete_dir(_data_path)
+            else:
+                logger.warning(
+                    "Directory '%s' already exists; export will be merged "
+                    "with existing files",
+                    _data_path,
+                )
+        elif os.path.isfile(_data_path):
+            if overwrite:
+                etau.delete_file(_data_path)
+
+    if labels_path is not None:
+        if os.path.isabs(labels_path) or export_dir is None:
+            _labels_path = labels_path
+        else:
+            _labels_path = os.path.join(export_dir, labels_path)
+
+        if os.path.isdir(_labels_path):
+            if overwrite:
+                etau.delete_dir(_labels_path)
+            else:
+                logger.warning(
+                    "Directory '%s' already exists; export will be merged "
+                    "with existing files",
+                    _labels_path,
+                )
+        elif os.path.isfile(_labels_path):
+            if overwrite:
+                etau.delete_file(_labels_path)
diff --git a/fiftyone/core/dataset.py b/fiftyone/core/dataset.py
index 4160b4ca076..db30168ad97 100644
--- a/fiftyone/core/dataset.py
+++ b/fiftyone/core/dataset.py
@@ -20,7 +20,7 @@
 from deprecated import deprecated
 import mongoengine.errors as moe
 import numpy as np
-from pymongo import UpdateMany, UpdateOne
+from pymongo import InsertOne, ReplaceOne, UpdateMany, UpdateOne
 from pymongo.errors import CursorNotFound, BulkWriteError
 
 import eta.core.serial as etas
@@ -1429,6 +1429,37 @@ def _add_samples_batch(self, samples, expand_schema, validate):
 
         return [str(d["_id"]) for d in dicts]
 
+    def _upsert_samples_batch(self, samples, expand_schema, validate):
+        if self.media_type is None and samples:
+            self.media_type = samples[0].media_type
+
+        if expand_schema:
+            self._expand_schema(samples)
+
+        if validate:
+            self._validate_samples(samples)
+
+        dicts = []
+        ops = []
+        for sample in samples:
+            d = sample.to_mongo_dict(include_id=True)
+            dicts.append(d)
+
+            if sample.id:
+                ops.append(ReplaceOne({"_id": sample._id}, d, upsert=True))
+            else:
+                d.pop("_id", None)
+                ops.append(InsertOne(d))  # adds `_id` to dict
+
+        foo.bulk_write(ops, self._sample_collection, ordered=False)
+
+        for sample, d in zip(samples, dicts):
+            doc = self._sample_dict_to_doc(d)
+            sample._set_backing_doc(doc, dataset=self)
+
+            if self.media_type == fom.VIDEO:
+                sample.frames.save()
+
     def _bulk_write(self, ops, frames=False, ordered=False):
         if frames:
             coll = self._frame_collection
@@ -1487,11 +1518,12 @@ def merge_samples(
         expand_schema=True,
         include_info=True,
         overwrite_info=False,
+        num_samples=None,
     ):
         """Merges the given samples into this dataset.
 
         By default, samples with the same absolute ``filepath`` are merged, but
-        can customize this behavior via the ``key_field`` and ``key_fcn``
+        you can customize this behavior via the ``key_field`` and ``key_fcn``
         parameters. For example, you could set
         ``key_fcn = lambda sample: os.path.basename(sample.filepath)`` to merge
         samples with the same base filename.
@@ -1518,8 +1550,7 @@ def merge_samples(
             as a whole rather than merging their elements
         -   Whether to merge (a) only specific fields or (b) all but certain
             fields
-        -   Mapping input collection fields to different field names of this
-            dataset
+        -   Mapping input fields to different field names of this dataset
 
         Args:
             samples: a :class:`fiftyone.core.collections.SampleCollection` or
@@ -1568,6 +1599,9 @@ def merge_samples(
                 information. Only applicable when ``samples`` is a
                 :class:`fiftyone.core.collections.SampleCollection` and
                 ``include_info`` is True
+            num_samples (None): the number of samples in ``samples``. If not
+                provided, this is computed via ``len(samples)``, if possible.
+                This value is optional and is used only for progress tracking
         """
         if fields is not None:
             if etau.is_str(fields):
@@ -1592,9 +1626,11 @@ def merge_samples(
                 overwrite_info=overwrite_info,
             )
 
-        # Use efficient implementation when possible
+            expand_schema = False
+
+        # If we're merging a collection, use aggregation pipelines
         if isinstance(samples, foc.SampleCollection) and key_fcn is None:
-            _merge_samples(
+            _merge_samples_pipeline(
                 samples,
                 self,
                 key_field,
@@ -1607,62 +1643,47 @@ def merge_samples(
             )
             return
 
-        if key_fcn is None:
-            id_map = {k: v for k, v in zip(*self.values([key_field, "id"]))}
-            key_fcn = lambda sample: sample[key_field]
-        else:
-            id_map = {}
-            logger.info("Indexing dataset...")
-            with fou.ProgressBar() as pb:
-                for sample in pb(self):
-                    id_map[key_fcn(sample)] = sample.id
+        #
+        # If we're not merging a collection but the merge key is a field, it's
+        # faster to import into a temporary dataset and then do a merge that
+        # leverages aggregation pipelines, because this avoids the need to
+        # load samples from `self` into memory
+        #
 
-        # When inserting new samples, `filepath` cannot be excluded
-        if insert_new:
-            if isinstance(fields, dict):
-                insert_fields = fields.copy()
-                insert_fields["filepath"] = "filepath"
-            elif fields is not None:
-                insert_fields = fields.copy()
-                if "filepath" not in insert_fields:
-                    insert_fields = ["filepath"] + insert_fields
-            else:
-                insert_fields = None
+        if key_fcn is None:
+            tmp = Dataset()
+            tmp.add_samples(samples, num_samples=num_samples)
 
-            insert_omit_fields = omit_fields
-            if insert_omit_fields is not None:
-                insert_omit_fields = [
-                    f for f in insert_omit_fields if f != "filepath"
-                ]
+            self.merge_samples(
+                tmp,
+                key_field=key_field,
+                skip_existing=skip_existing,
+                insert_new=insert_new,
+                fields=fields,
+                omit_fields=omit_fields,
+                merge_lists=merge_lists,
+                overwrite=overwrite,
+                expand_schema=expand_schema,
+                include_info=False,
+            )
+            tmp.delete()
 
-        logger.info("Merging samples...")
-        with fou.ProgressBar() as pb:
-            for sample in pb(samples):
-                key = key_fcn(sample)
-                if key in id_map:
-                    if not skip_existing:
-                        existing_sample = self[id_map[key]]
-                        existing_sample.merge(
-                            sample,
-                            fields=fields,
-                            omit_fields=omit_fields,
-                            merge_lists=merge_lists,
-                            overwrite=overwrite,
-                            expand_schema=expand_schema,
-                        )
-                        existing_sample.save()
-
-                elif insert_new:
-                    if (
-                        insert_fields is not None
-                        or insert_omit_fields is not None
-                    ):
-                        sample = sample.copy(
-                            fields=insert_fields,
-                            omit_fields=insert_omit_fields,
-                        )
+            return
 
-                    self.add_sample(sample, expand_schema=expand_schema)
+        _merge_samples_python(
+            self,
+            samples,
+            key_field=key_field,
+            key_fcn=key_fcn,
+            skip_existing=skip_existing,
+            insert_new=insert_new,
+            fields=fields,
+            omit_fields=omit_fields,
+            merge_lists=merge_lists,
+            overwrite=overwrite,
+            expand_schema=expand_schema,
+            num_samples=num_samples,
+        )
 
     def delete_samples(self, samples_or_ids):
         """Deletes the given sample(s) from the dataset.
@@ -2083,8 +2104,10 @@ def delete(self):
 
     def add_dir(
         self,
-        dataset_dir,
-        dataset_type,
+        dataset_dir=None,
+        dataset_type=None,
+        data_path=None,
+        labels_path=None,
         label_field="ground_truth",
         tags=None,
         expand_schema=True,
@@ -2093,14 +2116,68 @@ def add_dir(
     ):
         """Adds the contents of the given directory to the dataset.
 
-        See :doc:`this guide </user_guide/dataset_creation/datasets>` for
-        descriptions of available dataset types.
+        You can perform imports with this method via the following basic
+        patterns:
+
+        (a) Provide ``dataset_dir`` and ``dataset_type`` to import the contents
+            of a directory that is organized in the default layout for the
+            dataset type as documented in
+            :ref:`this guide <loading-datasets-from-disk>`
+
+        (b) Provide ``dataset_type`` along with ``data_path``, ``labels_path``,
+            or other type-specific parameters to perform a customized import.
+            This syntax provides the flexibility to, for example, perform
+            labels-only imports or imports where the source media lies in a
+            different location than the labels
+
+        In either workflow, the remaining parameters of this method can be
+        provided to further configure the import.
+
+        See :ref:`this guide <loading-datasets-from-disk>` for example usages
+        of this method and descriptions of the available dataset types.
 
         Args:
-            dataset_dir: the dataset directory
+            dataset_dir (None): the dataset directory. This can be omitted for
+                certain dataset formats if you provide arguments such as
+                ``data_path`` and ``labels_path``
             dataset_type (None): the
                 :class:`fiftyone.types.dataset_types.Dataset` type of the
-                dataset in ``dataset_dir``
+                dataset
+            data_path (None): an optional parameter that enables explicit
+                control over the location of the exported media for certain
+                dataset types. Can be any of the following:
+
+                -   a folder name like ``"data"`` or ``"data/"`` specifying a
+                    subfolder of ``dataset_dir`` in which the media lies
+                -   an absolute directory path in which the media lies. In this
+                    case, the ``export_dir`` has no effect on the location of
+                    the data
+                -   a filename like ``"data.json"`` specifying the filename of
+                    a JSON manifest file in ``dataset_dir`` that maps UUIDs to
+                    media filepaths. Files of this format are generated when
+                    passing the ``export_media="manifest"`` option to
+                    :meth:`fiftyone.core.collections.SampleCollection.export`
+                -   an absolute filepath to a JSON manifest file. In this case,
+                    ``dataset_dir`` has no effect on the location of the data
+
+                By default, it is assumed that the data can be located in the
+                default location within ``dataset_dir`` for the dataset type
+            labels_path (None): an optional parameter that enables explicit
+                control over the location of the labels. Only applicable when
+                importing certain labeled dataset formats. Can be any of the
+                following:
+
+                -   a type-specific folder name like ``"labels"`` or
+                    ``"labels/"`` or a filename like ``"labels.json"`` or
+                    ``"labels.xml"`` specifying the location in ``dataset_dir``
+                    of the labels file(s)
+                -   an absolute directory or filepath containing the labels
+                    file(s). In this case, ``dataset_dir`` has no effect on the
+                    location of the labels
+
+                For labeled datasets, this parameter defaults to the location
+                in ``dataset_dir`` of the labels for the default layout of the
+                dataset type being imported
             label_field ("ground_truth"): the name (or root name) of the
                 field(s) to use for the labels (if applicable)
             tags (None): an optional tag or iterable of tags to attach to each
@@ -2112,8 +2189,7 @@ def add_dir(
                 any) to the dataset's ``info``
             **kwargs: optional keyword arguments to pass to the constructor of
                 the :class:`fiftyone.utils.data.importers.DatasetImporter` for
-                the specified ``dataset_type`` via the syntax
-                ``DatasetImporter(dataset_dir, **kwargs)``
+                the specified ``dataset_type``
 
         Returns:
             a list of IDs of the samples that were added to the dataset
@@ -2122,7 +2198,7 @@ def add_dir(
             dataset_type = dataset_type()
 
         # If the input dataset contains TFRecords, they must be unpacked into a
-        # temporary directory during conversion
+        # directory during import
         if (
             isinstance(
                 dataset_type,
@@ -2137,19 +2213,13 @@ def add_dir(
             logger.info("Unpacking images to '%s'", images_dir)
             kwargs["images_dir"] = images_dir
 
-        dataset_importer_cls = dataset_type.get_dataset_importer_cls()
-
-        try:
-            dataset_importer = dataset_importer_cls(dataset_dir, **kwargs)
-        except Exception as e:
-            importer_name = dataset_importer_cls.__name__
-            raise ValueError(
-                "Failed to construct importer using syntax "
-                "%s(dataset_dir, **kwargs); you may need to supply mandatory "
-                "arguments to the constructor via `kwargs`. Please consult "
-                "the documentation of `%s` to learn more"
-                % (importer_name, etau.get_class_name(dataset_importer_cls))
-            ) from e
+        dataset_importer, _ = foud.build_dataset_importer(
+            dataset_type,
+            dataset_dir=dataset_dir,
+            data_path=data_path,
+            labels_path=labels_path,
+            **kwargs,
+        )
 
         return self.add_importer(
             dataset_importer,
@@ -2159,25 +2229,344 @@ def add_dir(
             add_info=add_info,
         )
 
+    def merge_dir(
+        self,
+        dataset_dir=None,
+        dataset_type=None,
+        data_path=None,
+        labels_path=None,
+        label_field="ground_truth",
+        tags=None,
+        key_field="filepath",
+        key_fcn=None,
+        skip_existing=False,
+        insert_new=True,
+        fields=None,
+        omit_fields=None,
+        merge_lists=True,
+        overwrite=True,
+        expand_schema=True,
+        add_info=True,
+        **kwargs,
+    ):
+        """Merges the contents of the given directory into the dataset.
+
+        You can perform imports with this method via the following basic
+        patterns:
+
+        (a) Provide ``dataset_dir`` and ``dataset_type`` to import the contents
+            of a directory that is organized in the default layout for the
+            dataset type as documented in
+            :ref:`this guide <loading-datasets-from-disk>`
+
+        (b) Provide ``dataset_type`` along with ``data_path``, ``labels_path``,
+            or other type-specific parameters to perform a customized import.
+            This syntax provides the flexibility to, for example, perform
+            labels-only imports or imports where the source media lies in a
+            different location than the labels
+
+        In either workflow, the remaining parameters of this method can be
+        provided to further configure the import.
+
+        See :ref:`this guide <loading-datasets-from-disk>` for example usages
+        of this method and descriptions of the available dataset types.
+
+        By default, samples with the same absolute ``filepath`` are merged, but
+        you can customize this behavior via the ``key_field`` and ``key_fcn``
+        parameters. For example, you could set
+        ``key_fcn = lambda sample: os.path.basename(sample.filepath)`` to merge
+        samples with the same base filename.
+
+        The behavior of this method is highly customizable. By default, all
+        top-level fields from the imported samples are merged in, overwriting
+        any existing values for those fields, with the exception of list fields
+        (e.g., ``tags``) and label list fields (e.g.,
+        :class:`fiftyone.core.labels.Detections` fields), in which case the
+        elements of the lists themselves are merged. In the case of label list
+        fields, labels with the same ``id`` in both collections are updated
+        rather than duplicated.
+
+        To avoid confusion between missing fields and fields whose value is
+        ``None``, ``None``-valued fields are always treated as missing while
+        merging.
+
+        This method can be configured in numerous ways, including:
+
+        -   Whether existing samples should be modified or skipped
+        -   Whether new samples should be added or omitted
+        -   Whether new fields can be added to the dataset schema
+        -   Whether list fields should be treated as ordinary fields and merged
+            as a whole rather than merging their elements
+        -   Whether to merge (a) only specific fields or (b) all but certain
+            fields
+        -   Mapping input fields to different field names of this dataset
+
+        Args:
+            dataset_dir (None): the dataset directory. This can be omitted for
+                certain dataset formats if you provide arguments such as
+                ``data_path`` and ``labels_path``
+            dataset_type (None): the
+                :class:`fiftyone.types.dataset_types.Dataset` type of the
+                dataset
+            data_path (None): an optional parameter that enables explicit
+                control over the location of the exported media for certain
+                dataset types. Can be any of the following:
+
+                -   a folder name like ``"data"`` or ``"data/"`` specifying a
+                    subfolder of ``dataset_dir`` in which the media lies
+                -   an absolute directory path in which the media lies. In this
+                    case, the ``export_dir`` has no effect on the location of
+                    the data
+                -   a filename like ``"data.json"`` specifying the filename of
+                    a JSON manifest file in ``dataset_dir`` that maps UUIDs to
+                    media filepaths. Files of this format are generated when
+                    passing the ``export_media="manifest"`` option to
+                    :meth:`fiftyone.core.collections.SampleCollection.export`
+                -   an absolute filepath to a JSON manifest file. In this case,
+                    ``dataset_dir`` has no effect on the location of the data
+
+                By default, it is assumed that the data can be located in the
+                default location within ``dataset_dir`` for the dataset type
+            labels_path (None): an optional parameter that enables explicit
+                control over the location of the labels. Only applicable when
+                importing certain labeled dataset formats. Can be any of the
+                following:
+
+                -   a type-specific folder name like ``"labels"`` or
+                    ``"labels/"`` or a filename like ``"labels.json"`` or
+                    ``"labels.xml"`` specifying the location in ``dataset_dir``
+                    of the labels file(s)
+                -   an absolute directory or filepath containing the labels
+                    file(s). In this case, ``dataset_dir`` has no effect on the
+                    location of the labels
+
+                For labeled datasets, this parameter defaults to the location
+                in ``dataset_dir`` of the labels for the default layout of the
+                dataset type being imported
+            label_field ("ground_truth"): the name (or root name) of the
+                field(s) to use for the labels (if applicable)
+            tags (None): an optional tag or iterable of tags to attach to each
+                sample
+            key_field ("filepath"): the sample field to use to decide whether
+                to join with an existing sample
+            key_fcn (None): a function that accepts a
+                :class:`fiftyone.core.sample.Sample` instance and computes a
+                key to decide if two samples should be merged. If a ``key_fcn``
+                is provided, ``key_field`` is ignored
+            skip_existing (False): whether to skip existing samples (True) or
+                merge them (False)
+            insert_new (True): whether to insert new samples (True) or skip
+                them (False)
+            fields (None): an optional field or iterable of fields to which to
+                restrict the merge. If provided, fields other than these are
+                omitted from ``samples`` when merging or adding samples. One
+                exception is that ``filepath`` is always included when adding
+                new samples, since the field is required. This can also be a
+                dict mapping field names of the input collection to field names
+                of this dataset
+            omit_fields (None): an optional field or iterable of fields to
+                exclude from the merge. If provided, these fields are omitted
+                from imported samples, if present. One exception is that
+                ``filepath`` is always included when adding new samples, since
+                the field is required
+            merge_lists (True): whether to merge the elements of list fields
+                (e.g., ``tags``) and label list fields (e.g.,
+                :class:`fiftyone.core.labels.Detections` fields) rather than
+                merging the entire top-level field like other field types. For
+                label lists fields, existing :class:`fiftyone.core.label.Label`
+                elements are either replaced (when ``overwrite`` is True) or
+                kept (when ``overwrite`` is False) when their ``id`` matches a
+                label from the provided samples
+            overwrite (True): whether to overwrite (True) or skip (False)
+                existing fields and label elements
+            expand_schema (True): whether to dynamically add new fields
+                encountered to the dataset schema. If False, an error is raised
+                if a sample's schema is not a subset of the dataset schema
+            add_info (True): whether to add dataset info from the importer
+                (if any) to the dataset
+            **kwargs: optional keyword arguments to pass to the constructor of
+                the :class:`fiftyone.utils.data.importers.DatasetImporter` for
+                the specified ``dataset_type``
+        """
+        if inspect.isclass(dataset_type):
+            dataset_type = dataset_type()
+
+        # If the input dataset contains TFRecords, they must be unpacked into a
+        # directory during import
+        if (
+            isinstance(
+                dataset_type,
+                (
+                    fot.TFImageClassificationDataset,
+                    fot.TFObjectDetectionDataset,
+                ),
+            )
+            and "images_dir" not in kwargs
+        ):
+            images_dir = get_default_dataset_dir(self.name)
+            logger.info("Unpacking images to '%s'", images_dir)
+            kwargs["images_dir"] = images_dir
+
+        dataset_importer, _ = foud.build_dataset_importer(
+            dataset_type,
+            dataset_dir=dataset_dir,
+            data_path=data_path,
+            labels_path=labels_path,
+            **kwargs,
+        )
+
+        return self.merge_importer(
+            dataset_importer,
+            label_field=label_field,
+            tags=tags,
+            key_field=key_field,
+            key_fcn=key_fcn,
+            skip_existing=skip_existing,
+            insert_new=insert_new,
+            fields=fields,
+            omit_fields=omit_fields,
+            merge_lists=merge_lists,
+            overwrite=overwrite,
+            expand_schema=expand_schema,
+            add_info=add_info,
+        )
+
     def add_archive(
         self,
         archive_path,
-        dataset_type,
-        cleanup=True,
+        dataset_type=None,
+        data_path=None,
+        labels_path=None,
         label_field="ground_truth",
         tags=None,
         expand_schema=True,
         add_info=True,
+        cleanup=True,
         **kwargs,
     ):
         """Adds the contents of the given archive to the dataset.
 
-        If the archive does not exist but a dataset with the same root name
+        If the archive does not exist but a directory with the same root name
+        does exist, it is assumed that this directory contains the extracted
+        contents of the archive.
+
+        See :ref:`this guide <loading-datasets-from-disk>` for example usages
+        of this method and descriptions of the available dataset types.
+
+        .. note::
+
+            The following archive formats are explicitly supported::
+
+                .zip, .tar, .tar.gz, .tgz, .tar.bz, .tbz
+
+            If an archive *not* in the above list is found, extraction will be
+            attempted via the ``patool`` package, which supports many formats
+            but may require that additional system packages be installed.
+
+        Args:
+            archive_path: the path to an archive of a dataset directory
+            dataset_type (None): the
+                :class:`fiftyone.types.dataset_types.Dataset` type of the
+                dataset in ``archive_path``
+            data_path (None): an optional parameter that enables explicit
+                control over the location of the exported media for certain
+                dataset types. Can be any of the following:
+
+                -   a folder name like ``"data"`` or ``"data/"`` specifying a
+                    subfolder of ``dataset_dir`` in which the media lies
+                -   an absolute directory path in which the media lies. In this
+                    case, the ``archive_path`` has no effect on the location of
+                    the data
+                -   a filename like ``"data.json"`` specifying the filename of
+                    a JSON manifest file in ``archive_path`` that maps UUIDs to
+                    media filepaths. Files of this format are generated when
+                    passing the ``export_media="manifest"`` option to
+                    :meth:`fiftyone.core.collections.SampleCollection.export`
+                -   an absolute filepath to a JSON manifest file. In this case,
+                    ``archive_path`` has no effect on the location of the data
+
+                By default, it is assumed that the data can be located in the
+                default location within ``archive_path`` for the dataset type
+            labels_path (None): an optional parameter that enables explicit
+                control over the location of the labels. Only applicable when
+                importing certain labeled dataset formats. Can be any of the
+                following:
+
+                -   a type-specific folder name like ``"labels"`` or
+                    ``"labels/"`` or a filename like ``"labels.json"`` or
+                    ``"labels.xml"`` specifying the location in
+                    ``archive_path`` of the labels file(s)
+                -   an absolute directory or filepath containing the labels
+                    file(s). In this case, ``archive_path`` has no effect on
+                    the location of the labels
+
+                For labeled datasets, this parameter defaults to the location
+                in ``archive_path`` of the labels for the default layout of the
+                dataset type being imported
+            label_field ("ground_truth"): the name (or root name) of the
+                field(s) to use for the labels (if applicable)
+            tags (None): an optional tag or iterable of tags to attach to each
+                sample
+            expand_schema (True): whether to dynamically add new sample fields
+                encountered to the dataset schema. If False, an error is raised
+                if a sample's schema is not a subset of the dataset schema
+            add_info (True): whether to add dataset info from the importer (if
+                any) to the dataset's ``info``
+            cleanup (True): whether to delete the archive after extracting it
+            **kwargs: optional keyword arguments to pass to the constructor of
+                the :class:`fiftyone.utils.data.importers.DatasetImporter` for
+                the specified ``dataset_type``
+
+        Returns:
+            a list of IDs of the samples that were added to the dataset
+        """
+        dataset_dir = etau.split_archive(archive_path)[0]
+        if os.path.isfile(archive_path) or not os.path.isdir(dataset_dir):
+            etau.extract_archive(
+                archive_path, outdir=dataset_dir, delete_archive=cleanup
+            )
+
+        return self.add_dir(
+            dataset_dir=dataset_dir,
+            dataset_type=dataset_type,
+            data_path=data_path,
+            labels_path=labels_path,
+            label_field=label_field,
+            tags=tags,
+            expand_schema=expand_schema,
+            add_info=add_info,
+            **kwargs,
+        )
+
+    def merge_archive(
+        self,
+        archive_path,
+        dataset_type=None,
+        data_path=None,
+        labels_path=None,
+        label_field="ground_truth",
+        tags=None,
+        key_field="filepath",
+        key_fcn=None,
+        skip_existing=False,
+        insert_new=True,
+        fields=None,
+        omit_fields=None,
+        merge_lists=True,
+        overwrite=True,
+        expand_schema=True,
+        add_info=True,
+        cleanup=True,
+        **kwargs,
+    ):
+        """Merges the contents of the given archive into the dataset.
+
+        If the archive does not exist but a directory with the same root name
         does exist, it is assumed that this directory contains the extracted
         contents of the archive.
 
-        See :doc:`this guide </user_guide/dataset_creation/datasets>` for
-        descriptions of available dataset types.
+        See :ref:`this guide <loading-datasets-from-disk>` for example usages
+        of this method and descriptions of the available dataset types.
 
         .. note::
 
@@ -2189,28 +2578,121 @@ def add_archive(
             attempted via the ``patool`` package, which supports many formats
             but may require that additional system packages be installed.
 
+        By default, samples with the same absolute ``filepath`` are merged, but
+        you can customize this behavior via the ``key_field`` and ``key_fcn``
+        parameters. For example, you could set
+        ``key_fcn = lambda sample: os.path.basename(sample.filepath)`` to merge
+        samples with the same base filename.
+
+        The behavior of this method is highly customizable. By default, all
+        top-level fields from the imported samples are merged in, overwriting
+        any existing values for those fields, with the exception of list fields
+        (e.g., ``tags``) and label list fields (e.g.,
+        :class:`fiftyone.core.labels.Detections` fields), in which case the
+        elements of the lists themselves are merged. In the case of label list
+        fields, labels with the same ``id`` in both collections are updated
+        rather than duplicated.
+
+        To avoid confusion between missing fields and fields whose value is
+        ``None``, ``None``-valued fields are always treated as missing while
+        merging.
+
+        This method can be configured in numerous ways, including:
+
+        -   Whether existing samples should be modified or skipped
+        -   Whether new samples should be added or omitted
+        -   Whether new fields can be added to the dataset schema
+        -   Whether list fields should be treated as ordinary fields and merged
+            as a whole rather than merging their elements
+        -   Whether to merge (a) only specific fields or (b) all but certain
+            fields
+        -   Mapping input fields to different field names of this dataset
+
         Args:
             archive_path: the path to an archive of a dataset directory
             dataset_type (None): the
                 :class:`fiftyone.types.dataset_types.Dataset` type of the
                 dataset in ``archive_path``
-            cleanup (True): whether to delete the archive after extracting it
+            data_path (None): an optional parameter that enables explicit
+                control over the location of the exported media for certain
+                dataset types. Can be any of the following:
+
+                -   a folder name like ``"data"`` or ``"data/"`` specifying a
+                    subfolder of ``dataset_dir`` in which the media lies
+                -   an absolute directory path in which the media lies. In this
+                    case, the ``archive_path`` has no effect on the location of
+                    the data
+                -   a filename like ``"data.json"`` specifying the filename of
+                    a JSON manifest file in ``archive_path`` that maps UUIDs to
+                    media filepaths. Files of this format are generated when
+                    passing the ``export_media="manifest"`` option to
+                    :meth:`fiftyone.core.collections.SampleCollection.export`
+                -   an absolute filepath to a JSON manifest file. In this case,
+                    ``archive_path`` has no effect on the location of the data
+
+                By default, it is assumed that the data can be located in the
+                default location within ``archive_path`` for the dataset type
+            labels_path (None): an optional parameter that enables explicit
+                control over the location of the labels. Only applicable when
+                importing certain labeled dataset formats. Can be any of the
+                following:
+
+                -   a type-specific folder name like ``"labels"`` or
+                    ``"labels/"`` or a filename like ``"labels.json"`` or
+                    ``"labels.xml"`` specifying the location in
+                    ``archive_path`` of the labels file(s)
+                -   an absolute directory or filepath containing the labels
+                    file(s). In this case, ``archive_path`` has no effect on
+                    the location of the labels
+
+                For labeled datasets, this parameter defaults to the location
+                in ``archive_path`` of the labels for the default layout of the
+                dataset type being imported
             label_field ("ground_truth"): the name (or root name) of the
                 field(s) to use for the labels (if applicable)
             tags (None): an optional tag or iterable of tags to attach to each
                 sample
-            expand_schema (True): whether to dynamically add new sample fields
+            key_field ("filepath"): the sample field to use to decide whether
+                to join with an existing sample
+            key_fcn (None): a function that accepts a
+                :class:`fiftyone.core.sample.Sample` instance and computes a
+                key to decide if two samples should be merged. If a ``key_fcn``
+                is provided, ``key_field`` is ignored
+            skip_existing (False): whether to skip existing samples (True) or
+                merge them (False)
+            insert_new (True): whether to insert new samples (True) or skip
+                them (False)
+            fields (None): an optional field or iterable of fields to which to
+                restrict the merge. If provided, fields other than these are
+                omitted from ``samples`` when merging or adding samples. One
+                exception is that ``filepath`` is always included when adding
+                new samples, since the field is required. This can also be a
+                dict mapping field names of the input collection to field names
+                of this dataset
+            omit_fields (None): an optional field or iterable of fields to
+                exclude from the merge. If provided, these fields are omitted
+                from imported samples, if present. One exception is that
+                ``filepath`` is always included when adding new samples, since
+                the field is required
+            merge_lists (True): whether to merge the elements of list fields
+                (e.g., ``tags``) and label list fields (e.g.,
+                :class:`fiftyone.core.labels.Detections` fields) rather than
+                merging the entire top-level field like other field types. For
+                label lists fields, existing :class:`fiftyone.core.label.Label`
+                elements are either replaced (when ``overwrite`` is True) or
+                kept (when ``overwrite`` is False) when their ``id`` matches a
+                label from the provided samples
+            overwrite (True): whether to overwrite (True) or skip (False)
+                existing fields and label elements
+            expand_schema (True): whether to dynamically add new fields
                 encountered to the dataset schema. If False, an error is raised
                 if a sample's schema is not a subset of the dataset schema
-            add_info (True): whether to add dataset info from the importer (if
-                any) to the dataset's ``info``
+            add_info (True): whether to add dataset info from the importer
+                (if any) to the dataset
+            cleanup (True): whether to delete the archive after extracting it
             **kwargs: optional keyword arguments to pass to the constructor of
                 the :class:`fiftyone.utils.data.importers.DatasetImporter` for
-                the specified ``dataset_type`` via the syntax
-                ``DatasetImporter(dataset_dir, **kwargs)``
-
-        Returns:
-            a list of IDs of the samples that were added to the dataset
+                the specified ``dataset_type``
         """
         dataset_dir = etau.split_archive(archive_path)[0]
         if os.path.isfile(archive_path) or not os.path.isdir(dataset_dir):
@@ -2218,11 +2700,21 @@ def add_archive(
                 archive_path, outdir=dataset_dir, delete_archive=cleanup
             )
 
-        return self.add_dir(
-            dataset_dir,
-            dataset_type,
+        return self.merge_dir(
+            dataset_dir=dataset_dir,
+            dataset_type=dataset_type,
+            data_path=data_path,
+            labels_path=labels_path,
             label_field=label_field,
             tags=tags,
+            key_field=key_field,
+            key_fcn=key_fcn,
+            skip_existing=skip_existing,
+            insert_new=insert_new,
+            fields=fields,
+            omit_fields=omit_fields,
+            merge_lists=merge_lists,
+            overwrite=overwrite,
             expand_schema=expand_schema,
             add_info=add_info,
             **kwargs,
@@ -2246,8 +2738,9 @@ def add_importer(
         Args:
             dataset_importer: a
                 :class:`fiftyone.utils.data.importers.DatasetImporter`
-            label_field ("ground_truth"): the name (or root name) of the
-                field(s) to use for the labels (if applicable)
+            label_field (None): the name (or root name) of the field(s) to use
+                for the labels. Only applicable if ``dataset_importer`` is a
+                :class:`fiftyone.utils.data.importers.LabeledImageDatasetImporter`
             tags (None): an optional tag or iterable of tags to attach to each
                 sample
             expand_schema (True): whether to dynamically add new sample fields
@@ -2268,7 +2761,124 @@ def add_importer(
             add_info=add_info,
         )
 
-    def add_images(self, samples, sample_parser=None, tags=None):
+    def merge_importer(
+        self,
+        dataset_importer,
+        label_field="ground_truth",
+        tags=None,
+        key_field="filepath",
+        key_fcn=None,
+        skip_existing=False,
+        insert_new=True,
+        fields=None,
+        omit_fields=None,
+        merge_lists=True,
+        overwrite=True,
+        expand_schema=True,
+        add_info=True,
+    ):
+        """Merges the samples from the given
+        :class:`fiftyone.utils.data.importers.DatasetImporter` into the
+        dataset.
+
+        See :ref:`this guide <custom-dataset-importer>` for more details about
+        importing datasets in custom formats by defining your own
+        :class:`DatasetImporter <fiftyone.utils.data.importers.DatasetImporter>`.
+
+        By default, samples with the same absolute ``filepath`` are merged, but
+        you can customize this behavior via the ``key_field`` and ``key_fcn``
+        parameters. For example, you could set
+        ``key_fcn = lambda sample: os.path.basename(sample.filepath)`` to merge
+        samples with the same base filename.
+
+        The behavior of this method is highly customizable. By default, all
+        top-level fields from the imported samples are merged in, overwriting
+        any existing values for those fields, with the exception of list fields
+        (e.g., ``tags``) and label list fields (e.g.,
+        :class:`fiftyone.core.labels.Detections` fields), in which case the
+        elements of the lists themselves are merged. In the case of label list
+        fields, labels with the same ``id`` in both collections are updated
+        rather than duplicated.
+
+        To avoid confusion between missing fields and fields whose value is
+        ``None``, ``None``-valued fields are always treated as missing while
+        merging.
+
+        This method can be configured in numerous ways, including:
+
+        -   Whether existing samples should be modified or skipped
+        -   Whether new samples should be added or omitted
+        -   Whether new fields can be added to the dataset schema
+        -   Whether list fields should be treated as ordinary fields and merged
+            as a whole rather than merging their elements
+        -   Whether to merge (a) only specific fields or (b) all but certain
+            fields
+        -   Mapping input fields to different field names of this dataset
+
+        Args:
+            dataset_importer: a
+                :class:`fiftyone.utils.data.importers.DatasetImporter`
+            label_field (None): the name (or root name) of the field(s) to use
+                for the labels. Only applicable if ``dataset_importer`` is a
+                :class:`fiftyone.utils.data.importers.LabeledImageDatasetImporter`
+            tags (None): an optional tag or iterable of tags to attach to each
+                sample
+            key_field ("filepath"): the sample field to use to decide whether
+                to join with an existing sample
+            key_fcn (None): a function that accepts a
+                :class:`fiftyone.core.sample.Sample` instance and computes a
+                key to decide if two samples should be merged. If a ``key_fcn``
+                is provided, ``key_field`` is ignored
+            skip_existing (False): whether to skip existing samples (True) or
+                merge them (False)
+            insert_new (True): whether to insert new samples (True) or skip
+                them (False)
+            fields (None): an optional field or iterable of fields to which to
+                restrict the merge. If provided, fields other than these are
+                omitted from ``samples`` when merging or adding samples. One
+                exception is that ``filepath`` is always included when adding
+                new samples, since the field is required. This can also be a
+                dict mapping field names of the input collection to field names
+                of this dataset
+            omit_fields (None): an optional field or iterable of fields to
+                exclude from the merge. If provided, these fields are omitted
+                from imported samples, if present. One exception is that
+                ``filepath`` is always included when adding new samples, since
+                the field is required
+            merge_lists (True): whether to merge the elements of list fields
+                (e.g., ``tags``) and label list fields (e.g.,
+                :class:`fiftyone.core.labels.Detections` fields) rather than
+                merging the entire top-level field like other field types. For
+                label lists fields, existing :class:`fiftyone.core.label.Label`
+                elements are either replaced (when ``overwrite`` is True) or
+                kept (when ``overwrite`` is False) when their ``id`` matches a
+                label from the provided samples
+            overwrite (True): whether to overwrite (True) or skip (False)
+                existing fields and label elements
+            expand_schema (True): whether to dynamically add new fields
+                encountered to the dataset schema. If False, an error is raised
+                if a sample's schema is not a subset of the dataset schema
+            add_info (True): whether to add dataset info from the importer
+                (if any) to the dataset
+        """
+        return foud.merge_samples(
+            self,
+            dataset_importer,
+            label_field=label_field,
+            tags=tags,
+            key_field=key_field,
+            key_fcn=key_fcn,
+            skip_existing=skip_existing,
+            insert_new=insert_new,
+            fields=fields,
+            omit_fields=omit_fields,
+            merge_lists=merge_lists,
+            overwrite=overwrite,
+            expand_schema=expand_schema,
+            add_info=add_info,
+        )
+
+    def add_images(self, paths_or_samples, sample_parser=None, tags=None):
         """Adds the given images to the dataset.
 
         This operation does not read the images.
@@ -2278,13 +2888,13 @@ def add_images(self, samples, sample_parser=None, tags=None):
         :class:`UnlabeledImageSampleParser <fiftyone.utils.data.parsers.UnlabeledImageSampleParser>`.
 
         Args:
-            samples: an iterable of data. If no ``sample_parser`` is provided,
-                this must be an iterable of image paths. If a ``sample_parser``
-                is provided, this can be an arbitrary iterable whose elements
-                can be parsed by the sample parser
+            paths_or_samples: an iterable of data. If no ``sample_parser`` is
+                provided, this must be an iterable of image paths. If a
+                ``sample_parser`` is provided, this can be an arbitrary
+                iterable whose elements can be parsed by the sample parser
             sample_parser (None): a
                 :class:`fiftyone.utils.data.parsers.UnlabeledImageSampleParser`
-                instance to use to parse ``samples``
+                instance to use to parse the samples
             tags (None): an optional tag or iterable of tags to attach to each
                 sample
 
@@ -2294,7 +2904,9 @@ def add_images(self, samples, sample_parser=None, tags=None):
         if sample_parser is None:
             sample_parser = foud.ImageSampleParser()
 
-        return foud.add_images(self, samples, sample_parser, tags=tags)
+        return foud.add_images(
+            self, paths_or_samples, sample_parser, tags=tags
+        )
 
     def add_labeled_images(
         self,
@@ -2318,7 +2930,7 @@ def add_labeled_images(
             samples: an iterable of data
             sample_parser: a
                 :class:`fiftyone.utils.data.parsers.LabeledImageSampleParser`
-                instance to use to parse ``samples``
+                instance to use to parse the samples
             label_field ("ground_truth"): the name (or root name) of the
                 field(s) to use for the labels (if applicable)
             tags (None): an optional tag or iterable of tags to attach to each
@@ -2381,7 +2993,7 @@ def add_images_patt(self, images_patt, tags=None):
 
     def ingest_images(
         self,
-        samples,
+        paths_or_samples,
         sample_parser=None,
         tags=None,
         dataset_dir=None,
@@ -2396,13 +3008,13 @@ def ingest_images(
         :class:`UnlabeledImageSampleParser <fiftyone.utils.data.parsers.UnlabeledImageSampleParser>`.
 
         Args:
-            samples: an iterable of data. If no ``sample_parser`` is
+            paths_or_samples: an iterable of data. If no ``sample_parser`` is
                 provided, this must be an iterable of image paths. If a
                 ``sample_parser`` is provided, this can be an arbitrary
                 iterable whose elements can be parsed by the sample parser
             sample_parser (None): a
                 :class:`fiftyone.utils.data.parsers.UnlabeledImageSampleParser`
-                instance to use to parse ``samples``
+                instance to use to parse the samples
             tags (None): an optional tag or iterable of tags to attach to each
                 sample
             dataset_dir (None): the directory in which the images will be
@@ -2420,7 +3032,10 @@ def ingest_images(
             dataset_dir = get_default_dataset_dir(self.name)
 
         dataset_ingestor = foud.UnlabeledImageDatasetIngestor(
-            dataset_dir, samples, sample_parser, image_format=image_format,
+            dataset_dir,
+            paths_or_samples,
+            sample_parser,
+            image_format=image_format,
         )
 
         return self.add_importer(dataset_ingestor, tags=tags)
@@ -2433,7 +3048,6 @@ def ingest_labeled_images(
         tags=None,
         expand_schema=True,
         dataset_dir=None,
-        skip_unlabeled=False,
         image_format=None,
     ):
         """Ingests the given iterable of labeled image samples into the
@@ -2449,7 +3063,7 @@ def ingest_labeled_images(
             samples: an iterable of data
             sample_parser: a
                 :class:`fiftyone.utils.data.parsers.LabeledImageSampleParser`
-                instance to use to parse ``samples``
+                instance to use to parse the samples
             label_field ("ground_truth"): the name (or root name) of the
                 field(s) to use for the labels (if applicable)
             tags (None): an optional tag or iterable of tags to attach to each
@@ -2459,8 +3073,6 @@ def ingest_labeled_images(
                 if the sample's schema is not a subset of the dataset schema
             dataset_dir (None): the directory in which the images will be
                 written. By default, :func:`get_default_dataset_dir` is used
-            skip_unlabeled (False): whether to skip unlabeled images when
-                importing
             image_format (None): the image format to use to write the images to
                 disk. By default, ``fiftyone.config.default_image_ext`` is used
 
@@ -2471,11 +3083,7 @@ def ingest_labeled_images(
             dataset_dir = get_default_dataset_dir(self.name)
 
         dataset_ingestor = foud.LabeledImageDatasetIngestor(
-            dataset_dir,
-            samples,
-            sample_parser,
-            skip_unlabeled=skip_unlabeled,
-            image_format=image_format,
+            dataset_dir, samples, sample_parser, image_format=image_format,
         )
 
         return self.add_importer(
@@ -2485,7 +3093,7 @@ def ingest_labeled_images(
             expand_schema=expand_schema,
         )
 
-    def add_videos(self, samples, sample_parser=None, tags=None):
+    def add_videos(self, paths_or_samples, sample_parser=None, tags=None):
         """Adds the given videos to the dataset.
 
         This operation does not read the videos.
@@ -2495,13 +3103,13 @@ def add_videos(self, samples, sample_parser=None, tags=None):
         :class:`UnlabeledVideoSampleParser <fiftyone.utils.data.parsers.UnlabeledVideoSampleParser>`.
 
         Args:
-            samples: an iterable of data. If no ``sample_parser`` is provided,
-                this must be an iterable of video paths. If a ``sample_parser``
-                is provided, this can be an arbitrary iterable whose elements
-                can be parsed by the sample parser
+            paths_or_samples: an iterable of data. If no ``sample_parser`` is
+                provided, this must be an iterable of video paths. If a
+                ``sample_parser`` is provided, this can be an arbitrary
+                iterable whose elements can be parsed by the sample parser
             sample_parser (None): a
-                :class:`fiftyone.utils.data.parsers.UnlabeledImageSampleParser`
-                instance to use to parse ``samples``
+                :class:`fiftyone.utils.data.parsers.UnlabeledVideoSampleParser`
+                instance to use to parse the samples
             tags (None): an optional tag or iterable of tags to attach to each
                 sample
 
@@ -2511,7 +3119,9 @@ def add_videos(self, samples, sample_parser=None, tags=None):
         if sample_parser is None:
             sample_parser = foud.VideoSampleParser()
 
-        return foud.add_videos(self, samples, sample_parser, tags=tags)
+        return foud.add_videos(
+            self, paths_or_samples, sample_parser, tags=tags
+        )
 
     def add_labeled_videos(
         self,
@@ -2534,7 +3144,7 @@ def add_labeled_videos(
             samples: an iterable of data
             sample_parser: a
                 :class:`fiftyone.utils.data.parsers.LabeledVideoSampleParser`
-                instance to use to parse ``samples``
+                instance to use to parse the samples
             label_field ("ground_truth"): the name (or root name) of the
                 frame field(s) to use for the labels
             tags (None): an optional tag or iterable of tags to attach to each
@@ -2596,7 +3206,7 @@ def add_videos_patt(self, videos_patt, tags=None):
         return self.add_videos(video_paths, sample_parser, tags=tags)
 
     def ingest_videos(
-        self, samples, sample_parser=None, tags=None, dataset_dir=None,
+        self, paths_or_samples, sample_parser=None, tags=None, dataset_dir=None
     ):
         """Ingests the given iterable of videos into the dataset.
 
@@ -2607,13 +3217,13 @@ def ingest_videos(
         :class:`UnlabeledVideoSampleParser <fiftyone.utils.data.parsers.UnlabeledVideoSampleParser>`.
 
         Args:
-            samples: an iterable of data. If no ``sample_parser`` is provided,
-                this must be an iterable of video paths. If a ``sample_parser``
-                is provided, this can be an arbitrary iterable whose elements
-                can be parsed by the sample parser
+            paths_or_samples: an iterable of data. If no ``sample_parser`` is
+                provided, this must be an iterable of video paths. If a
+                ``sample_parser`` is provided, this can be an arbitrary
+                iterable whose elements can be parsed by the sample parser
             sample_parser (None): a
-                :class:`fiftyone.utils.data.parsers.UnlabeledImageSampleParser`
-                instance to use to parse ``samples``
+                :class:`fiftyone.utils.data.parsers.UnlabeledVideoSampleParser`
+                instance to use to parse the samples
             tags (None): an optional tag or iterable of tags to attach to each
                 sample
             dataset_dir (None): the directory in which the videos will be
@@ -2629,7 +3239,7 @@ def ingest_videos(
             dataset_dir = get_default_dataset_dir(self.name)
 
         dataset_ingestor = foud.UnlabeledVideoDatasetIngestor(
-            dataset_dir, samples, sample_parser
+            dataset_dir, paths_or_samples, sample_parser
         )
 
         return self.add_importer(dataset_ingestor, tags=tags)
@@ -2641,7 +3251,6 @@ def ingest_labeled_videos(
         tags=None,
         expand_schema=True,
         dataset_dir=None,
-        skip_unlabeled=False,
     ):
         """Ingests the given iterable of labeled video samples into the
         dataset.
@@ -2656,7 +3265,7 @@ def ingest_labeled_videos(
             samples: an iterable of data
             sample_parser: a
                 :class:`fiftyone.utils.data.parsers.LabeledVideoSampleParser`
-                instance to use to parse ``samples``
+                instance to use to parse the samples
             tags (None): an optional tag or iterable of tags to attach to each
                 sample
             expand_schema (True): whether to dynamically add new sample fields
@@ -2664,8 +3273,6 @@ def ingest_labeled_videos(
                 if the sample's schema is not a subset of the dataset schema
             dataset_dir (None): the directory in which the videos will be
                 written. By default, :func:`get_default_dataset_dir` is used
-            skip_unlabeled (False): whether to skip unlabeled videos when
-                importing
 
         Returns:
             a list of IDs of the samples in the dataset
@@ -2674,7 +3281,7 @@ def ingest_labeled_videos(
             dataset_dir = get_default_dataset_dir(self.name)
 
         dataset_ingestor = foud.LabeledVideoDatasetIngestor(
-            dataset_dir, samples, sample_parser, skip_unlabeled=skip_unlabeled,
+            dataset_dir, samples, sample_parser
         )
 
         return self.add_importer(
@@ -2684,8 +3291,10 @@ def ingest_labeled_videos(
     @classmethod
     def from_dir(
         cls,
-        dataset_dir,
-        dataset_type,
+        dataset_dir=None,
+        dataset_type=None,
+        data_path=None,
+        labels_path=None,
         name=None,
         label_field="ground_truth",
         tags=None,
@@ -2693,13 +3302,67 @@ def from_dir(
     ):
         """Creates a :class:`Dataset` from the contents of the given directory.
 
-        See :doc:`this guide </user_guide/dataset_creation/datasets>` for
-        descriptions of available dataset types.
+        You can create datasets with this method via the following basic
+        patterns:
+
+        (a) Provide ``dataset_dir`` and ``dataset_type`` to import the contents
+            of a directory that is organized in the default layout for the
+            dataset type as documented in
+            :ref:`this guide <loading-datasets-from-disk>`
+
+        (b) Provide ``dataset_type`` along with ``data_path``, ``labels_path``,
+            or other type-specific parameters to perform a customized
+            import. This syntax provides the flexibility to, for example,
+            perform labels-only imports or imports where the source media lies
+            in a different location than the labels
+
+        In either workflow, the remaining parameters of this method can be
+        provided to further configure the import.
+
+        See :ref:`this guide <loading-datasets-from-disk>` for example usages
+        of this method and descriptions of the available dataset types.
 
         Args:
-            dataset_dir: the dataset directory
-            dataset_type: the :class:`fiftyone.types.dataset_types.Dataset`
-                type of the dataset in ``dataset_dir``
+            dataset_dir (None): the dataset directory. This can be omitted if
+                you provide arguments such as ``data_path`` and ``labels_path``
+            dataset_type (None): the
+                :class:`fiftyone.types.dataset_types.Dataset` type of the
+                dataset
+            data_path (None): an optional parameter that enables explicit
+                control over the location of the exported media for certain
+                dataset types. Can be any of the following:
+
+                -   a folder name like ``"data"`` or ``"data/"`` specifying a
+                    subfolder of ``dataset_dir`` in which the media lies
+                -   an absolute directory path in which the media lies. In this
+                    case, the ``export_dir`` has no effect on the location of
+                    the data
+                -   a filename like ``"data.json"`` specifying the filename of
+                    a JSON manifest file in ``dataset_dir`` that maps UUIDs to
+                    media filepaths. Files of this format are generated when
+                    passing the ``export_media="manifest"`` option to
+                    :meth:`fiftyone.core.collections.SampleCollection.export`
+                -   an absolute filepath to a JSON manifest file. In this case,
+                    ``dataset_dir`` has no effect on the location of the data
+
+                By default, it is assumed that the data can be located in the
+                default location within ``dataset_dir`` for the dataset type
+            labels_path (None): an optional parameter that enables explicit
+                control over the location of the labels. Only applicable when
+                importing certain labeled dataset formats. Can be any of the
+                following:
+
+                -   a type-specific folder name like ``"labels"`` or
+                    ``"labels/"`` or a filename like ``"labels.json"`` or
+                    ``"labels.xml"`` specifying the location in ``dataset_dir``
+                    of the labels file(s)
+                -   an absolute directory or filepath containing the labels
+                    file(s). In this case, ``dataset_dir`` has no effect on the
+                    location of the labels
+
+                For labeled datasets, this parameter defaults to the location
+                in ``dataset_dir`` of the labels for the default layout of the
+                dataset type being imported
             name (None): a name for the dataset. By default,
                 :func:`get_default_dataset_name` is used
             label_field ("ground_truth"): the name (or root name) of the
@@ -2708,16 +3371,17 @@ def from_dir(
                 sample
             **kwargs: optional keyword arguments to pass to the constructor of
                 the :class:`fiftyone.utils.data.importers.DatasetImporter` for
-                the specified ``dataset_type`` via the syntax
-                ``DatasetImporter(dataset_dir, **kwargs)``
+                the specified ``dataset_type``
 
         Returns:
             a :class:`Dataset`
         """
         dataset = cls(name)
         dataset.add_dir(
-            dataset_dir,
-            dataset_type,
+            dataset_dir=dataset_dir,
+            dataset_type=dataset_type,
+            data_path=data_path,
+            labels_path=labels_path,
             label_field=label_field,
             tags=tags,
             **kwargs,
@@ -2728,21 +3392,23 @@ def from_dir(
     def from_archive(
         cls,
         archive_path,
-        dataset_type,
-        cleanup=True,
+        dataset_type=None,
+        data_path=None,
+        labels_path=None,
         name=None,
         label_field="ground_truth",
         tags=None,
+        cleanup=True,
         **kwargs,
     ):
         """Creates a :class:`Dataset` from the contents of the given archive.
 
-        If the archive does not exist but a dataset with the same root name
+        If the archive does not exist but a directory with the same root name
         does exist, it is assumed that this directory contains the extracted
         contents of the archive.
 
-        See :doc:`this guide </user_guide/dataset_creation/datasets>` for
-        descriptions of available dataset types.
+        See :ref:`this guide <loading-datasets-from-disk>` for example usages
+        of this method and descriptions of the available dataset types.
 
         .. note::
 
@@ -2756,19 +3422,54 @@ def from_archive(
 
         Args:
             archive_path: the path to an archive of a dataset directory
-            dataset_type: the :class:`fiftyone.types.dataset_types.Dataset`
-                type of the dataset in ``archive_path``
-            cleanup (True): whether to delete the archive after extracting it
+            dataset_type (None): the
+                :class:`fiftyone.types.dataset_types.Dataset` type of the
+                dataset in ``archive_path``
+            data_path (None): an optional parameter that enables explicit
+                control over the location of the exported media for certain
+                dataset types. Can be any of the following:
+
+                -   a folder name like ``"data"`` or ``"data/"`` specifying a
+                    subfolder of ``dataset_dir`` in which the media lies
+                -   an absolute directory path in which the media lies. In this
+                    case, the ``archive_path`` has no effect on the location of
+                    the data
+                -   a filename like ``"data.json"`` specifying the filename of
+                    a JSON manifest file in ``archive_path`` that maps UUIDs to
+                    media filepaths. Files of this format are generated when
+                    passing the ``export_media="manifest"`` option to
+                    :meth:`fiftyone.core.collections.SampleCollection.export`
+                -   an absolute filepath to a JSON manifest file. In this case,
+                    ``archive_path`` has no effect on the location of the data
+
+                By default, it is assumed that the data can be located in the
+                default location within ``archive_path`` for the dataset type
+            labels_path (None): an optional parameter that enables explicit
+                control over the location of the labels. Only applicable when
+                importing certain labeled dataset formats. Can be any of the
+                following:
+
+                -   a type-specific folder name like ``"labels"`` or
+                    ``"labels/"`` or a filename like ``"labels.json"`` or
+                    ``"labels.xml"`` specifying the location in
+                    ``archive_path`` of the labels file(s)
+                -   an absolute directory or filepath containing the labels
+                    file(s). In this case, ``archive_path`` has no effect on
+                    the location of the labels
+
+                For labeled datasets, this parameter defaults to the location
+                in ``archive_path`` of the labels for the default layout of the
+                dataset type being imported
             name (None): a name for the dataset. By default,
                 :func:`get_default_dataset_name` is used
             label_field ("ground_truth"): the name (or root name) of the
                 field(s) to use for the labels (if applicable)
             tags (None): an optional tag or iterable of tags to attach to each
                 sample
+            cleanup (True): whether to delete the archive after extracting it
             **kwargs: optional keyword arguments to pass to the constructor of
                 the :class:`fiftyone.utils.data.importers.DatasetImporter` for
-                the specified ``dataset_type`` via the syntax
-                ``DatasetImporter(dataset_dir, **kwargs)``
+                the specified ``dataset_type``
 
         Returns:
             a :class:`Dataset`
@@ -2776,10 +3477,12 @@ def from_archive(
         dataset = cls(name)
         dataset.add_archive(
             archive_path,
-            dataset_type,
-            cleanup=cleanup,
+            dataset_type=dataset_type,
+            data_path=data_path,
+            labels_path=labels_path,
             label_field=label_field,
             tags=tags,
+            cleanup=cleanup,
             **kwargs,
         )
         return dataset
@@ -2816,7 +3519,9 @@ def from_importer(
         return dataset
 
     @classmethod
-    def from_images(cls, samples, sample_parser, name=None, tags=None):
+    def from_images(
+        cls, paths_or_samples, sample_parser=None, name=None, tags=None
+    ):
         """Creates a :class:`Dataset` from the given images.
 
         This operation does not read the images.
@@ -2827,10 +3532,13 @@ def from_images(cls, samples, sample_parser, name=None, tags=None):
         to load image samples into FiftyOne.
 
         Args:
-            samples: an iterable of data
-            sample_parser: a
+            paths_or_samples: an iterable of data. If no ``sample_parser`` is
+                provided, this must be an iterable of image paths. If a
+                ``sample_parser`` is provided, this can be an arbitrary
+                iterable whose elements can be parsed by the sample parser
+            sample_parser (None): a
                 :class:`fiftyone.utils.data.parsers.UnlabeledImageSampleParser`
-                instance to use to parse ``samples``
+                instance to use to parse the samples
             name (None): a name for the dataset. By default,
                 :func:`get_default_dataset_name` is used
             tags (None): an optional tag or iterable of tags to attach to each
@@ -2840,7 +3548,9 @@ def from_images(cls, samples, sample_parser, name=None, tags=None):
             a :class:`Dataset`
         """
         dataset = cls(name)
-        dataset.add_images(samples, sample_parser, tags=tags)
+        dataset.add_images(
+            paths_or_samples, sample_parser=sample_parser, tags=tags
+        )
         return dataset
 
     @classmethod
@@ -2866,7 +3576,7 @@ def from_labeled_images(
             samples: an iterable of data
             sample_parser: a
                 :class:`fiftyone.utils.data.parsers.LabeledImageSampleParser`
-                instance to use to parse ``samples``
+                instance to use to parse the samples
             name (None): a name for the dataset. By default,
                 :func:`get_default_dataset_name` is used
             label_field ("ground_truth"): the name (or root name) of the
@@ -2926,7 +3636,9 @@ def from_images_patt(cls, images_patt, name=None, tags=None):
         return dataset
 
     @classmethod
-    def from_videos(cls, samples, sample_parser, name=None, tags=None):
+    def from_videos(
+        cls, paths_or_samples, sample_parser=None, name=None, tags=None
+    ):
         """Creates a :class:`Dataset` from the given videos.
 
         This operation does not read/decode the videos.
@@ -2937,10 +3649,13 @@ def from_videos(cls, samples, sample_parser, name=None, tags=None):
         to load video samples into FiftyOne.
 
         Args:
-            samples: an iterable of data
-            sample_parser: a
+            paths_or_samples: an iterable of data. If no ``sample_parser`` is
+                provided, this must be an iterable of video paths. If a
+                ``sample_parser`` is provided, this can be an arbitrary
+                iterable whose elements can be parsed by the sample parser
+            sample_parser (None): a
                 :class:`fiftyone.utils.data.parsers.UnlabeledVideoSampleParser`
-                instance to use to parse ``samples``
+                instance to use to parse the samples
             name (None): a name for the dataset. By default,
                 :func:`get_default_dataset_name` is used
             tags (None): an optional tag or iterable of tags to attach to each
@@ -2950,7 +3665,9 @@ def from_videos(cls, samples, sample_parser, name=None, tags=None):
             a :class:`Dataset`
         """
         dataset = cls(name)
-        dataset.add_videos(samples, sample_parser, tags=tags)
+        dataset.add_videos(
+            paths_or_samples, sample_parser=sample_parser, tags=tags
+        )
         return dataset
 
     @classmethod
@@ -2971,7 +3688,7 @@ def from_labeled_videos(
             samples: an iterable of data
             sample_parser: a
                 :class:`fiftyone.utils.data.parsers.LabeledVideoSampleParser`
-                instance to use to parse ``samples``
+                instance to use to parse the samples
             name (None): a name for the dataset. By default,
                 :func:`get_default_dataset_name` is used
             tags (None): an optional tag or iterable of tags to attach to each
@@ -4002,7 +4719,123 @@ def _ensure_index(dataset, field, unique=False):
     return new, dropped
 
 
-def _merge_samples(
+def _merge_samples_python(
+    dataset,
+    samples,
+    key_field=None,
+    key_fcn=None,
+    skip_existing=False,
+    insert_new=True,
+    fields=None,
+    omit_fields=None,
+    merge_lists=True,
+    overwrite=True,
+    expand_schema=True,
+    num_samples=None,
+):
+    if num_samples is None:
+        try:
+            num_samples = len(samples)
+        except:
+            pass
+
+    if key_fcn is None:
+        id_map = {k: v for k, v in zip(*dataset.values([key_field, "id"]))}
+        key_fcn = lambda sample: sample[key_field]
+    else:
+        id_map = {}
+        logger.info("Indexing dataset...")
+        with fou.ProgressBar() as pb:
+            for sample in pb(dataset):
+                id_map[key_fcn(sample)] = sample.id
+
+    _samples = _make_merge_samples_generator(
+        dataset,
+        samples,
+        key_fcn,
+        id_map,
+        skip_existing=skip_existing,
+        insert_new=insert_new,
+        fields=fields,
+        omit_fields=omit_fields,
+        merge_lists=merge_lists,
+        overwrite=overwrite,
+        expand_schema=expand_schema,
+    )
+
+    # Dynamically size batches so that they are as large as possible while
+    # still achieving a nice frame rate on the progress bar
+    target_latency = 0.2  # in seconds
+    batcher = fou.DynamicBatcher(
+        _samples, target_latency, init_batch_size=1, max_batch_beta=2.0
+    )
+
+    logger.info("Merging samples...")
+    with fou.ProgressBar(total=num_samples) as pb:
+        for batch in batcher:
+            dataset._upsert_samples_batch(batch, expand_schema, True)
+            pb.update(count=len(batch))
+
+
+def _make_merge_samples_generator(
+    dataset,
+    samples,
+    key_fcn,
+    id_map,
+    skip_existing=False,
+    insert_new=True,
+    fields=None,
+    omit_fields=None,
+    merge_lists=True,
+    overwrite=True,
+    expand_schema=True,
+):
+    # When inserting new samples, `filepath` cannot be excluded
+    if insert_new:
+        if isinstance(fields, dict):
+            insert_fields = fields.copy()
+            insert_fields["filepath"] = "filepath"
+        elif fields is not None:
+            insert_fields = fields.copy()
+            if "filepath" not in insert_fields:
+                insert_fields = ["filepath"] + insert_fields
+        else:
+            insert_fields = None
+
+        insert_omit_fields = omit_fields
+        if insert_omit_fields is not None:
+            insert_omit_fields = [
+                f for f in insert_omit_fields if f != "filepath"
+            ]
+
+    for sample in samples:
+        key = key_fcn(sample)
+        if key in id_map:
+            if not skip_existing:
+                existing_sample = dataset[id_map[key]]
+                existing_sample.merge(
+                    sample,
+                    fields=fields,
+                    omit_fields=omit_fields,
+                    merge_lists=merge_lists,
+                    overwrite=overwrite,
+                    expand_schema=expand_schema,
+                )
+
+                yield existing_sample
+
+        elif insert_new:
+            if insert_fields is not None or insert_omit_fields is not None:
+                sample = sample.copy(
+                    fields=insert_fields, omit_fields=insert_omit_fields
+                )
+            elif sample._in_db:
+                sample = sample.copy()
+
+            yield sample
+
+
+def _merge_samples_pipeline(
     src_collection,
     dst_dataset,
     key_field,
@@ -4017,8 +4850,9 @@ def _merge_samples(
     # Prepare for merge
     #
 
-    if key_field == "id":
-        key_field = "_id"
+    db_fields_map = src_collection._get_db_fields_map()
+
+    key_field = db_fields_map.get(key_field, key_field)
 
     is_video = dst_dataset.media_type == fom.VIDEO
     src_dataset = src_collection._dataset
@@ -4108,8 +4942,6 @@ def _merge_samples(
     )
     default_fields.discard("id")
 
-    db_fields_map = src_collection._get_db_fields_map()
-
     sample_pipeline = src_collection._pipeline(detach_frames=True)
 
     if fields is not None:
diff --git a/fiftyone/core/eta_utils.py b/fiftyone/core/eta_utils.py
index 80f4701ee13..c783f3c0fe1 100644
--- a/fiftyone/core/eta_utils.py
+++ b/fiftyone/core/eta_utils.py
@@ -448,7 +448,7 @@ def to_image_labels(labels):
         return image_labels
 
     if not isinstance(labels, dict):
-        labels = {labels: labels}
+        labels = {"labels": labels}
 
     for name, label in labels.items():
         if isinstance(label, fol.ImageLabel):
diff --git a/fiftyone/core/media.py b/fiftyone/core/media.py
index 9b90e2e2c46..d96c0a10149 100644
--- a/fiftyone/core/media.py
+++ b/fiftyone/core/media.py
@@ -37,14 +37,14 @@ def get_media_type(filepath):
     return IMAGE
 
 
-def export_media(inpaths, outpaths, move_media=False, num_workers=None):
+def export_media(inpaths, outpaths, mode="copy", num_workers=None):
     """Exports the media at the given input paths to the given output paths.
 
     Args:
         inpaths: the list of input paths
         outpaths: the list of output paths
-        move_media (False): whether to move (True) or copy (False) the source
-            media into its output destination
+        mode ("copy"): the export mode to use. Supported values are
+            ``("copy", "move", "symlink")``
         num_workers (None): the number of processes to use. By default,
             ``multiprocessing.cpu_count()`` is used
     """
@@ -56,7 +56,17 @@ def export_media(inpaths, outpaths, move_media=False, num_workers=None):
         num_workers = multiprocessing.cpu_count()
 
     inputs = list(zip(inpaths, outpaths))
-    op = _do_move_file if move_media else _do_copy_file
+    if mode == "copy":
+        op = _do_copy_file
+    elif mode == "move":
+        op = _do_move_file
+    elif mode == "symlink":
+        op = _do_symlink_file
+    else:
+        raise ValueError(
+            "Unsupported mode '%s'. Supported values are %s"
+            % (mode, ("copy", "move", "symlink"))
+        )
 
     with fou.ProgressBar(total=num_files, iters_str="files") as pb:
         with multiprocessing.Pool(processes=num_workers) as pool:
@@ -74,6 +84,11 @@ def _do_copy_file(args):
     etau.copy_file(inpath, outpath)
 
 
+def _do_symlink_file(args):
+    inpath, outpath = args
+    etau.symlink_file(inpath, outpath)
+
+
 class MediaTypeError(TypeError):
     """Exception raised when a problem with media types is encountered."""
 
diff --git a/fiftyone/core/models.py b/fiftyone/core/models.py
index fa34910c134..4a945f9fa1c 100644
--- a/fiftyone/core/models.py
+++ b/fiftyone/core/models.py
@@ -1184,8 +1184,8 @@ def compute_patch_embeddings(
         force_square (False): whether to minimally manipulate the patch
             bounding boxes into squares prior to extraction
         alpha (None): an optional expansion/contraction to apply to the patches
-            before extracting them, in ``[-1, \infty)``. If provided, the
-            length and width of the box are expanded (or contracted, when
+            before extracting them, in ``[-1, inf)``. If provided, the length
+            and width of the box are expanded (or contracted, when
             ``alpha < 0``) by ``(100 * alpha)%``. For example, set
             ``alpha = 1.1`` to expand the boxes by 10%, and set ``alpha = 0.9``
             to contract the boxes by 10%
diff --git a/fiftyone/core/utils.py b/fiftyone/core/utils.py
index cdde7861f69..0f340de109d 100644
--- a/fiftyone/core/utils.py
+++ b/fiftyone/core/utils.py
@@ -840,14 +840,20 @@ class UniqueFilenameMaker(object):
     directory are generated.
 
     Args:
-        output_dir (""): the directory in which to generate output paths
-        default_ext (""): the file extension to use when generating default
+        output_dir (None): a directory in which to generate output paths
+        default_ext (None): the file extension to use when generating default
             output paths
         ignore_exts (False): whether to omit file extensions when checking for
             duplicate filenames
     """
 
-    def __init__(self, output_dir="", default_ext="", ignore_exts=False):
+    def __init__(self, output_dir=None, default_ext=None, ignore_exts=False):
+        if output_dir is None:
+            output_dir = ""
+
+        if default_ext is None:
+            default_ext = ""
+
         self.output_dir = output_dir
         self.default_ext = default_ext
         self.ignore_exts = ignore_exts
diff --git a/fiftyone/types/dataset_types.py b/fiftyone/types/dataset_types.py
index 84b5e067296..8b88087f27d 100644
--- a/fiftyone/types/dataset_types.py
+++ b/fiftyone/types/dataset_types.py
@@ -776,9 +776,9 @@ def get_dataset_importer_cls(self):
         return fouo.OpenImagesV6DatasetImporter
 
 
-class YOLODataset(ImageDetectionDataset):
+class YOLOv4Dataset(ImageDetectionDataset):
     """A labeled dataset consisting of images and their associated object
-    detections saved in `YOLO format <https://github.com/AlexeyAB/darknet>`_.
+    detections saved in `YOLOv4 format <https://github.com/AlexeyAB/darknet>`_.
 
     Datasets of this type are read/written in the following format::
 
@@ -820,12 +820,75 @@ class YOLODataset(ImageDetectionDataset):
     def get_dataset_importer_cls(self):
         import fiftyone.utils.yolo as fouy
 
-        return fouy.YOLODatasetImporter
+        return fouy.YOLOv4DatasetImporter
 
     def get_dataset_exporter_cls(self):
         import fiftyone.utils.yolo as fouy
 
-        return fouy.YOLODatasetExporter
+        return fouy.YOLOv4DatasetExporter
+
+
+class YOLOv5Dataset(ImageDetectionDataset):
+    """A labeled dataset consisting of images and their associated object
+    detections saved in
+    `YOLOv5 format <https://github.com/ultralytics/yolov5>`_.
+
+    Datasets of this type are read/written in the following format::
+
+        <dataset_dir>/
+            dataset.yaml
+            images/
+                train/
+                    <uuid1>.<ext>
+                    <uuid2>.<ext>
+                    ...
+                val/
+                    <uuid3>.<ext>
+                    <uuid4>.<ext>
+                    ...
+            labels/
+                train/
+                    <uuid1>.txt
+                    <uuid2>.txt
+                    ...
+                val/
+                    <uuid3>.txt
+                    <uuid4>.txt
+                    ...
+
+    where ``dataset.yaml`` contains the following information::
+
+        train: ./images/train/
+        val: ./images/val/
+
+        # number of classes
+        nc: 80
+
+        # class names
+        names: ["list", "of", "classes", ...]
+
+    and the TXT files in ``labels/`` are space-delimited files where each row
+    corresponds to an object in the image of the same name, in the following
+    format::
+
+        <target> <x-center> <y-center> <width> <height>
+
+    where ``<target>`` is the zero-based integer index of the object class
+    label from ``names`` and the bounding box coordinates are expressed as
+    relative coordinates in ``[0, 1] x [0, 1]``.
+
+    Unlabeled images have no corresponding TXT file in ``labels/``.
+    """
+
+    def get_dataset_importer_cls(self):
+        import fiftyone.utils.yolo as fouy
+
+        return fouy.YOLOv5DatasetImporter
+
+    def get_dataset_exporter_cls(self):
+        import fiftyone.utils.yolo as fouy
+
+        return fouy.YOLOv5DatasetExporter
 
 
 class TFObjectDetectionDataset(ImageDetectionDataset):
@@ -1308,9 +1371,9 @@ def get_dataset_exporter_cls(self):
         return foub.BDDDatasetExporter
 
 
-class GeoJSONImageDataset(ImageLabelsDataset):
-    """An image dataset whose geolocation data and optional properties are
-    stored in `GeoJSON format <https://en.wikipedia.org/wiki/GeoJSON>`_.
+class GeoJSONDataset(LabeledDataset):
+    """An image or video dataset whose geolocation data and optional properties
+    are stored in `GeoJSON format <https://en.wikipedia.org/wiki/GeoJSON>`_.
 
     Datasets of this type are read/written in the following format::
 
@@ -1360,14 +1423,11 @@ class GeoJSONImageDataset(ImageLabelsDataset):
         }
 
     where the ``geometry`` field may contain any valid GeoJSON geometry object,
-    and the ``filename`` property encodes the name of the corresponding image
-    in the ``data/`` folder.
-
-    You can also specify a ``filepath`` property rather than ``filename``, in
-    which case the path is interpreted as an absolute path to the corresponding
-    image, which may or may not be in ``data/`` folder.
+    and the ``filename`` property encodes the name of the corresponding media
+    in the ``data/`` folder. The ``filename`` property can also be an absolute
+    path, which may or may not be in the ``data/`` folder.
 
-    Images with no location data will have a null ``geometry`` field.
+    Samples with no location data will have a null ``geometry`` field.
 
     The ``properties`` field of each feature can contain additional labels that
     can be imported/exported when working with datasets of this type.
@@ -1376,12 +1436,12 @@ class GeoJSONImageDataset(ImageLabelsDataset):
     def get_dataset_importer_cls(self):
         import fiftyone.utils.geojson as foug
 
-        return foug.GeoJSONImageDatasetImporter
+        return foug.GeoJSONDatasetImporter
 
     def get_dataset_exporter_cls(self):
         import fiftyone.utils.geojson as foug
 
-        return foug.GeoJSONImageDatasetExporter
+        return foug.GeoJSONDatasetExporter
 
 
 class FiftyOneVideoLabelsDataset(VideoLabelsDataset):
diff --git a/fiftyone/utils/bdd.py b/fiftyone/utils/bdd.py
index 435a7175c58..23329b9014b 100644
--- a/fiftyone/utils/bdd.py
+++ b/fiftyone/utils/bdd.py
@@ -18,7 +18,6 @@
 import fiftyone as fo
 import fiftyone.core.labels as fol
 import fiftyone.core.metadata as fom
-import fiftyone.core.utils as fou
 import fiftyone.utils.data as foud
 
 
@@ -152,14 +151,43 @@ def _parse_label(self, labels, img):
         return _parse_bdd_annotation(labels, frame_size)
 
 
-class BDDDatasetImporter(foud.LabeledImageDatasetImporter):
+class BDDDatasetImporter(
+    foud.LabeledImageDatasetImporter, foud.ImportPathsMixin
+):
     """Importer for BDD datasets stored on disk.
 
     See :class:`fiftyone.types.dataset_types.BDDDataset` for format details.
 
     Args:
-        dataset_dir: the dataset directory
-        skip_unlabeled (False): whether to skip unlabeled images when importing
+        dataset_dir (None): the dataset directory
+        data_path (None): an optional parameter that enables explicit control
+            over the location of the media. Can be any of the following:
+
+            -   a folder name like ``"data"`` or ``"data/"`` specifying a
+                subfolder of ``dataset_dir`` where the media files reside
+            -   an absolute directory path where the media files reside. In
+                this case, the ``dataset_dir`` has no effect on the location of
+                the data
+            -   a filename like ``"data.json"`` specifying the filename of the
+                JSON data manifest file in ``dataset_dir``
+            -   an absolute filepath specifying the location of the JSON data
+                manifest. In this case, ``dataset_dir`` has no effect on the
+                location of the data
+
+            If None, this parameter will default to whichever of ``data/`` or
+            ``data.json`` exists in the dataset directory
+        labels_path (None): an optional parameter that enables explicit control
+            over the location of the labels. Can be any of the following:
+
+            -   a filename like ``"labels.json"`` specifying the location of
+                the labels in ``dataset_dir``
+            -   an absolute filepath to the labels. In this case,
+                ``dataset_dir`` has no effect on the location of the labels
+
+            If None, the parameter will default to ``labels.json``
+        include_all_data (False): whether to generate samples for all images in
+            the data directory (True) rather than only creating samples for
+            images with label entries (False)
         shuffle (False): whether to randomly shuffle the order in which the
             samples are imported
         seed (None): a random seed to use when shuffling
@@ -169,21 +197,36 @@ class BDDDatasetImporter(foud.LabeledImageDatasetImporter):
 
     def __init__(
         self,
-        dataset_dir,
-        skip_unlabeled=False,
+        dataset_dir=None,
+        data_path=None,
+        labels_path=None,
+        include_all_data=False,
         shuffle=False,
         seed=None,
         max_samples=None,
     ):
+        data_path = self._parse_data_path(
+            dataset_dir=dataset_dir, data_path=data_path, default="data/",
+        )
+
+        labels_path = self._parse_labels_path(
+            dataset_dir=dataset_dir,
+            labels_path=labels_path,
+            default="labels.json",
+        )
+
         super().__init__(
-            dataset_dir,
-            skip_unlabeled=skip_unlabeled,
+            dataset_dir=dataset_dir,
             shuffle=shuffle,
             seed=seed,
             max_samples=max_samples,
         )
-        self._data_dir = None
-        self._labels_path = None
+
+        self.data_path = data_path
+        self.labels_path = labels_path
+        self.include_all_data = include_all_data
+
+        self._image_paths_map = None
         self._anno_dict_map = None
         self._filenames = None
         self._iter_filenames = None
@@ -199,7 +242,10 @@ def __len__(self):
     def __next__(self):
         filename = next(self._iter_filenames)
 
-        image_path = os.path.join(self._data_dir, filename)
+        if os.path.isabs(filename):
+            image_path = filename
+        else:
+            image_path = self._image_paths_map[filename]
 
         image_metadata = fom.ImageMetadata.build_for(image_path)
 
@@ -231,48 +277,116 @@ def label_cls(self):
         }
 
     def setup(self):
-        self._data_dir = os.path.join(self.dataset_dir, "data")
-        self._labels_path = os.path.join(self.dataset_dir, "labels.json")
-        if os.path.isfile(self._labels_path):
-            self._anno_dict_map = load_bdd_annotations(self._labels_path)
+        self._image_paths_map = self._load_data_map(self.data_path)
+
+        if self.labels_path is not None and os.path.isfile(self.labels_path):
+            self._anno_dict_map = load_bdd_annotations(self.labels_path)
         else:
             self._anno_dict_map = {}
 
-        filenames = etau.list_files(self._data_dir, abs_paths=False)
+        filenames = set(self._anno_dict_map.keys())
 
-        if self.skip_unlabeled:
-            filenames = [f for f in filenames if f in self._anno_dict_map]
+        if self.include_all_data:
+            filenames.update(self._image_paths_map.keys())
 
-        self._filenames = self._preprocess_list(filenames)
+        self._filenames = self._preprocess_list(sorted(filenames))
         self._num_samples = len(self._filenames)
 
     @staticmethod
-    def get_num_samples(dataset_dir):
+    def _get_num_samples(dataset_dir):
+        # Used only by dataset zoo
         return len(etau.list_files(os.path.join(dataset_dir, "data")))
 
 
-class BDDDatasetExporter(foud.LabeledImageDatasetExporter):
+class BDDDatasetExporter(
+    foud.LabeledImageDatasetExporter, foud.ExportPathsMixin
+):
     """Exporter that writes BDD datasets to disk.
 
     See :class:`fiftyone.types.dataset_types.BDDDataset` for format details.
 
     Args:
-        export_dir: the directory to write the export
+        export_dir (None): the directory to write the export. This has no
+            effect if ``data_path`` and ``labels_path`` are absolute paths
+        data_path (None): an optional parameter that enables explicit control
+            over the location of the exported media. Can be any of the
+            following:
+
+            -   a folder name like ``"data"`` or ``"data/"`` specifying a
+                subfolder of ``export_dir`` in which to export the media
+            -   an absolute directory path in which to export the media. In
+                this case, the ``export_dir`` has no effect on the location of
+                the data
+            -   a JSON filename like ``"data.json"`` specifying the filename of
+                the manifest file in ``export_dir`` generated when
+                ``export_media`` is ``"manifest"``
+            -   an absolute filepath specifying the location to write the JSON
+                manifest file when ``export_media`` is ``"manifest"``. In this
+                case, ``export_dir`` has no effect on the location of the data
+
+            If None, the default value of this parameter will be chosen based
+            on the value of the ``export_media`` parameter
+        labels_path (None): an optional parameter that enables explicit control
+            over the location of the exported labels. Can be any of the
+            following:
+
+            -   a filename like ``"labels.json"`` specifying the location in
+                ``export_dir`` in which to export the labels
+            -   an absolute filepath to which to export the labels. In this
+                case, the ``export_dir`` has no effect on the location of the
+                labels
+
+            If None, the labels will be exported into ``export_dir`` using the
+            default filename
+        export_media (None): controls how to export the raw media. The
+            supported values are:
+
+            -   ``True``: copy all media files into the output directory
+            -   ``False``: don't export media
+            -   ``"move"``: move all media files into the output directory
+            -   ``"symlink"``: create symlinks to the media files in the output
+                directory
+            -   ``"manifest"``: create a ``data.json`` in the output directory
+                that maps UUIDs used in the labels files to the filepaths of
+                the source media, rather than exporting the actual media
+
+            If None, the default value of this parameter will be chosen based
+            on the value of the ``data_path`` parameter
         image_format (None): the image format to use when writing in-memory
             images to disk. By default, ``fiftyone.config.default_image_ext``
             is used
     """
 
-    def __init__(self, export_dir, image_format=None):
-        if image_format is None:
-            image_format = fo.config.default_image_ext
+    def __init__(
+        self,
+        export_dir=None,
+        data_path=None,
+        labels_path=None,
+        export_media=None,
+        image_format=None,
+    ):
+        data_path, export_media = self._parse_data_path(
+            export_dir=export_dir,
+            data_path=data_path,
+            export_media=export_media,
+            default="data/",
+        )
 
-        super().__init__(export_dir)
+        labels_path = self._parse_labels_path(
+            export_dir=export_dir,
+            labels_path=labels_path,
+            default="labels.json",
+        )
+
+        super().__init__(export_dir=export_dir)
+
+        self.data_path = data_path
+        self.labels_path = labels_path
+        self.export_media = export_media
         self.image_format = image_format
-        self._data_dir = None
-        self._labels_path = None
+
         self._annotations = None
-        self._filename_maker = None
+        self._media_exporter = None
 
     @property
     def requires_image_metadata(self):
@@ -287,17 +401,16 @@ def label_cls(self):
         }
 
     def setup(self):
-        self._data_dir = os.path.join(self.export_dir, "data")
-        self._labels_path = os.path.join(self.export_dir, "labels.json")
         self._annotations = []
-        self._filename_maker = fou.UniqueFilenameMaker(
-            output_dir=self._data_dir, default_ext=self.image_format
+        self._media_exporter = foud.ImageExporter(
+            self.export_media,
+            export_path=self.data_path,
+            default_ext=self.image_format,
         )
+        self._media_exporter.setup()
 
     def export_sample(self, image_or_path, labels, metadata=None):
-        out_image_path = self._export_image_or_path(
-            image_or_path, self._filename_maker
-        )
+        out_image_path, _ = self._media_exporter.export(image_or_path)
 
         if labels is None:
             return  # unlabeled
@@ -316,7 +429,8 @@ def export_sample(self, image_or_path, labels, metadata=None):
         self._annotations.append(annotation)
 
     def close(self, *args):
-        etas.write_json(self._annotations, self._labels_path)
+        etas.write_json(self._annotations, self.labels_path)
+        self._media_exporter.close()
 
 
 def load_bdd_annotations(json_path):
diff --git a/fiftyone/utils/coco.py b/fiftyone/utils/coco.py
index a40f336d285..282d35556cc 100644
--- a/fiftyone/utils/coco.py
+++ b/fiftyone/utils/coco.py
@@ -21,7 +21,6 @@
 import eta.core.utils as etau
 import eta.core.web as etaw
 
-import fiftyone as fo
 import fiftyone.core.labels as fol
 import fiftyone.core.metadata as fom
 import fiftyone.core.utils as fou
@@ -139,14 +138,41 @@ def _parse_anno_dict(self, anno_dict, img):
         )
 
 
-class COCODetectionDatasetImporter(foud.LabeledImageDatasetImporter):
+class COCODetectionDatasetImporter(
+    foud.LabeledImageDatasetImporter, foud.ImportPathsMixin
+):
     """Importer for COCO detection datasets stored on disk.
 
     See :class:`fiftyone.types.dataset_types.COCODetectionDataset` for format
     details.
 
     Args:
-        dataset_dir: the dataset directory
+        dataset_dir (None): the dataset directory
+        data_path (None): an optional parameter that enables explicit control
+            over the location of the media. Can be any of the following:
+
+            -   a folder name like ``"data"`` or ``"data/"`` specifying a
+                subfolder of ``dataset_dir`` where the media files reside
+            -   an absolute directory path where the media files reside. In
+                this case, the ``dataset_dir`` has no effect on the location of
+                the data
+            -   a filename like ``"data.json"`` specifying the filename of the
+                JSON data manifest file in ``dataset_dir``
+            -   an absolute filepath specifying the location of the JSON data
+                manifest. In this case, ``dataset_dir`` has no effect on the
+                location of the data
+
+            If None, this parameter will default to whichever of ``data/`` or
+            ``data.json`` exists in the dataset directory
+        labels_path (None): an optional parameter that enables explicit control
+            over the location of the labels. Can be any of the following:
+
+            -   a filename like ``"labels.json"`` specifying the location of
+                the labels in ``dataset_dir``
+            -   an absolute filepath to the labels. In this case,
+                ``dataset_dir`` has no effect on the location of the labels
+
+            If None, the parameter will default to ``labels.json``
         load_segmentations (True): whether to load segmentation masks, if
             available
         return_polylines (False): whether to return
@@ -154,7 +180,6 @@ class COCODetectionDatasetImporter(foud.LabeledImageDatasetImporter):
             :class:`fiftyone.core.labels.Detections`
         tolerance (None): a tolerance, in pixels, when generating approximate
             polylines for instance masks. Typical values are 1-3 pixels
-        skip_unlabeled (False): whether to skip unlabeled images when importing
         shuffle (False): whether to randomly shuffle the order in which the
             samples are imported
         seed (None): a random seed to use when shuffling
@@ -164,30 +189,44 @@ class COCODetectionDatasetImporter(foud.LabeledImageDatasetImporter):
 
     def __init__(
         self,
-        dataset_dir,
+        dataset_dir=None,
+        data_path=None,
+        labels_path=None,
         load_segmentations=True,
         return_polylines=False,
         tolerance=None,
-        skip_unlabeled=False,
         shuffle=False,
         seed=None,
         max_samples=None,
     ):
+        data_path = self._parse_data_path(
+            dataset_dir=dataset_dir, data_path=data_path, default="data/",
+        )
+
+        labels_path = self._parse_labels_path(
+            dataset_dir=dataset_dir,
+            labels_path=labels_path,
+            default="labels.json",
+        )
+
         super().__init__(
-            dataset_dir,
-            skip_unlabeled=skip_unlabeled,
+            dataset_dir=dataset_dir,
             shuffle=shuffle,
             seed=seed,
             max_samples=max_samples,
         )
+
+        self.data_path = data_path
+        self.labels_path = labels_path
         self.load_segmentations = load_segmentations
         self.return_polylines = return_polylines
         self.tolerance = tolerance
-        self._data_dir = None
+
         self._info = None
         self._classes = None
         self._supercategory_map = None
-        self._images_map = None
+        self._image_paths_map = None
+        self._image_dicts_map = None
         self._annotations = None
         self._filenames = None
         self._iter_filenames = None
@@ -202,9 +241,12 @@ def __len__(self):
     def __next__(self):
         filename = next(self._iter_filenames)
 
-        image_path = os.path.join(self._data_dir, filename)
+        if os.path.isabs(filename):
+            image_path = filename
+        else:
+            image_path = self._image_paths_map[filename]
 
-        image_dict = self._images_map.get(filename, None)
+        image_dict = self._image_dicts_map.get(filename, None)
         if image_dict is None:
             image_metadata = fom.ImageMetadata.build_for(image_path)
             return image_path, image_metadata, None
@@ -256,17 +298,16 @@ def label_cls(self):
         return fol.Detections
 
     def setup(self):
-        self._data_dir = os.path.join(self.dataset_dir, "data")
+        self._image_paths_map = self._load_data_map(self.data_path)
 
-        labels_path = os.path.join(self.dataset_dir, "labels.json")
-        if os.path.isfile(labels_path):
+        if self.labels_path is not None and os.path.isfile(self.labels_path):
             (
                 info,
                 classes,
                 supercategory_map,
                 images,
                 annotations,
-            ) = load_coco_detection_annotations(labels_path)
+            ) = load_coco_detection_annotations(self.labels_path)
         else:
             info = {}
             classes = None
@@ -280,13 +321,10 @@ def setup(self):
         self._info = info
         self._classes = classes
         self._supercategory_map = supercategory_map
-        self._images_map = {i["file_name"]: i for i in images.values()}
+        self._image_dicts_map = {i["file_name"]: i for i in images.values()}
         self._annotations = annotations
 
-        if self.skip_unlabeled:
-            filenames = self._images_map.keys()
-        else:
-            filenames = etau.list_files(self._data_dir, abs_paths=False)
+        filenames = sorted(self._image_dicts_map.keys())
 
         self._filenames = self._preprocess_list(filenames)
 
@@ -294,14 +332,64 @@ def get_dataset_info(self):
         return self._info
 
 
-class COCODetectionDatasetExporter(foud.LabeledImageDatasetExporter):
+class COCODetectionDatasetExporter(
+    foud.LabeledImageDatasetExporter, foud.ExportPathsMixin
+):
     """Exporter that writes COCO detection datasets to disk.
 
     See :class:`fiftyone.types.dataset_types.COCODetectionDataset` for format
     details.
 
     Args:
-        export_dir: the directory to write the export
+        export_dir (None): the directory to write the export. This has no
+            effect if ``data_path`` and ``labels_path`` are absolute paths
+        data_path (None): an optional parameter that enables explicit control
+            over the location of the exported media. Can be any of the
+            following:
+
+            -   a folder name like ``"data"`` or ``"data/"`` specifying a
+                subfolder of ``export_dir`` in which to export the media
+            -   an absolute directory path in which to export the media. In
+                this case, the ``export_dir`` has no effect on the location of
+                the data
+            -   a JSON filename like ``"data.json"`` specifying the filename of
+                the manifest file in ``export_dir`` generated when
+                ``export_media`` is ``"manifest"``
+            -   an absolute filepath specifying the location to write the JSON
+                manifest file when ``export_media`` is ``"manifest"``. In this
+                case, ``export_dir`` has no effect on the location of the data
+
+            If None, the default value of this parameter will be chosen based
+            on the value of the ``export_media`` parameter
+        labels_path (None): an optional parameter that enables explicit control
+            over the location of the exported labels. Can be any of the
+            following:
+
+            -   a filename like ``"labels.json"`` specifying the location in
+                ``export_dir`` in which to export the labels
+            -   an absolute filepath to which to export the labels. In this
+                case, the ``export_dir`` has no effect on the location of the
+                labels
+
+            If None, the labels will be exported into ``export_dir`` using the
+            default filename
+        export_media (None): controls how to export the raw media. The
+            supported values are:
+
+            -   ``True``: copy all media files into the output directory
+            -   ``False``: don't export media
+            -   ``"move"``: move all media files into the output directory
+            -   ``"symlink"``: create symlinks to the media files in the output
+                directory
+            -   ``"manifest"``: create a ``data.json`` in the output directory
+                that maps UUIDs used in the labels files to the filepaths of
+                the source media, rather than exporting the actual media
+
+            If None, the default value of this parameter will be chosen based
+            on the value of the ``data_path`` parameter
+        image_format (None): the image format to use when writing in-memory
+            images to disk. By default, ``fiftyone.config.default_image_ext``
+            is used
         classes (None): the list of possible class labels. If not provided,
             this list will be extracted when :meth:`log_collection` is called,
             if possible
@@ -309,39 +397,52 @@ class COCODetectionDatasetExporter(foud.LabeledImageDatasetExporter):
             :meth:`load_coco_detection_annotations`. If not provided, this info
             will be extracted when :meth:`log_collection` is called, if
             possible
-        image_format (None): the image format to use when writing in-memory
-            images to disk. By default, ``fiftyone.config.default_image_ext``
-            is used
         tolerance (None): a tolerance, in pixels, when generating approximate
             polylines for instance masks. Typical values are 1-3 pixels
     """
 
     def __init__(
         self,
-        export_dir,
+        export_dir=None,
+        data_path=None,
+        labels_path=None,
+        export_media=None,
+        image_format=None,
         classes=None,
         info=None,
-        image_format=None,
         tolerance=None,
     ):
-        if image_format is None:
-            image_format = fo.config.default_image_ext
+        data_path, export_media = self._parse_data_path(
+            export_dir=export_dir,
+            data_path=data_path,
+            export_media=export_media,
+            default="data/",
+        )
+
+        labels_path = self._parse_labels_path(
+            export_dir=export_dir,
+            labels_path=labels_path,
+            default="labels.json",
+        )
+
+        super().__init__(export_dir=export_dir)
 
-        super().__init__(export_dir)
+        self.data_path = data_path
+        self.labels_path = labels_path
+        self.export_media = export_media
+        self.image_format = image_format
         self.classes = classes
         self.info = info
-        self.image_format = image_format
         self.tolerance = tolerance
+
         self._labels_map_rev = None
-        self._data_dir = None
-        self._labels_path = None
         self._image_id = None
         self._anno_id = None
         self._images = None
         self._annotations = None
         self._classes = None
-        self._filename_maker = None
         self._has_labels = None
+        self._media_exporter = None
 
     @property
     def requires_image_metadata(self):
@@ -352,19 +453,22 @@ def label_cls(self):
         return fol.Detections
 
     def setup(self):
-        self._data_dir = os.path.join(self.export_dir, "data")
-        self._labels_path = os.path.join(self.export_dir, "labels.json")
         self._image_id = -1
         self._anno_id = -1
         self._images = []
         self._annotations = []
         self._classes = set()
-        self._filename_maker = fou.UniqueFilenameMaker(
-            output_dir=self._data_dir, default_ext=self.image_format
-        )
         self._has_labels = False
+
         self._parse_classes()
 
+        self._media_exporter = foud.ImageExporter(
+            self.export_media,
+            export_path=self.data_path,
+            default_ext=self.image_format,
+        )
+        self._media_exporter.setup()
+
     def log_collection(self, sample_collection):
         if self.classes is None:
             if sample_collection.default_classes:
@@ -381,9 +485,7 @@ def log_collection(self, sample_collection):
             self.info = sample_collection.info
 
     def export_sample(self, image_or_path, detections, metadata=None):
-        out_image_path = self._export_image_or_path(
-            image_or_path, self._filename_maker
-        )
+        out_image_path, _ = self._media_exporter.export(image_or_path)
 
         if metadata is None:
             metadata = fom.ImageMetadata.build_for(out_image_path)
@@ -418,7 +520,6 @@ def export_sample(self, image_or_path, detections, metadata=None):
             self._annotations.append(obj.to_anno_dict())
 
     def close(self, *args):
-        # Populate observed category IDs, if necessary
         if self.classes is None:
             classes = sorted(self._classes)
             labels_map_rev = _to_labels_map_rev(classes)
@@ -456,7 +557,9 @@ def close(self, *args):
         if self._has_labels:
             labels["annotations"] = self._annotations
 
-        etas.write_json(labels, self._labels_path)
+        etas.write_json(labels, self.labels_path)
+
+        self._media_exporter.close()
 
     def _parse_classes(self):
         if self.classes is not None:
@@ -640,7 +743,7 @@ def from_detection(
             iscrowd = int(detection.get_attribute_value("iscrowd"))
         else:
             try:
-                iscrowd = detection["iscrowd"]
+                iscrowd = int(detection["iscrowd"])
             except KeyError:
                 iscrowd = None
 
diff --git a/fiftyone/utils/cvat.py b/fiftyone/utils/cvat.py
index 7fdf14cbe43..53d188769a0 100644
--- a/fiftyone/utils/cvat.py
+++ b/fiftyone/utils/cvat.py
@@ -20,7 +20,6 @@
 import eta.core.image as etai
 import eta.core.utils as etau
 
-import fiftyone as fo
 import fiftyone.constants as foc
 import fiftyone.core.labels as fol
 import fiftyone.core.metadata as fom
@@ -154,15 +153,44 @@ def get_frame_labels(self):
         return _cvat_tracks_to_frames_dict(cvat_tracks)
 
 
-class CVATImageDatasetImporter(foud.LabeledImageDatasetImporter):
+class CVATImageDatasetImporter(
+    foud.LabeledImageDatasetImporter, foud.ImportPathsMixin
+):
     """Importer for CVAT image datasets stored on disk.
 
     See :class:`fiftyone.types.dataset_types.CVATImageDataset` for format
     details.
 
     Args:
-        dataset_dir: the dataset directory
-        skip_unlabeled (False): whether to skip unlabeled images when importing
+        dataset_dir (None): the dataset directory
+        data_path (None): an optional parameter that enables explicit control
+            over the location of the media. Can be any of the following:
+
+            -   a folder name like ``"data"`` or ``"data/"`` specifying a
+                subfolder of ``dataset_dir`` where the media files reside
+            -   an absolute directory path where the media files reside. In
+                this case, the ``dataset_dir`` has no effect on the location of
+                the data
+            -   a filename like ``"data.json"`` specifying the filename of the
+                JSON data manifest file in ``dataset_dir``
+            -   an absolute filepath specifying the location of the JSON data
+                manifest. In this case, ``dataset_dir`` has no effect on the
+                location of the data
+
+            If None, this parameter will default to whichever of ``data/`` or
+            ``data.json`` exists in the dataset directory
+        labels_path (None): an optional parameter that enables explicit control
+            over the location of the labels. Can be any of the following:
+
+            -   a filename like ``"labels.xml"`` specifying the location of the
+                labels in ``dataset_dir``
+            -   an absolute filepath to the labels. In this case,
+                ``dataset_dir`` has no effect on the location of the labels
+
+            If None, the parameter will default to ``labels.xml``
+        include_all_data (False): whether to generate samples for all images in
+            the data directory (True) rather than only creating samples for
+            images with label entries (False)
         shuffle (False): whether to randomly shuffle the order in which the
             samples are imported
         seed (None): a random seed to use when shuffling
@@ -172,23 +200,38 @@ class CVATImageDatasetImporter(foud.LabeledImageDatasetImporter):
 
     def __init__(
         self,
-        dataset_dir,
-        skip_unlabeled=False,
+        dataset_dir=None,
+        data_path=None,
+        labels_path=None,
+        include_all_data=False,
         shuffle=False,
         seed=None,
         max_samples=None,
     ):
+        data_path = self._parse_data_path(
+            dataset_dir=dataset_dir, data_path=data_path, default="data/",
+        )
+
+        labels_path = self._parse_labels_path(
+            dataset_dir=dataset_dir,
+            labels_path=labels_path,
+            default="labels.xml",
+        )
+
         super().__init__(
-            dataset_dir,
-            skip_unlabeled=skip_unlabeled,
+            dataset_dir=dataset_dir,
             shuffle=shuffle,
             seed=seed,
             max_samples=max_samples,
         )
-        self._data_dir = None
-        self._labels_path = None
+
+        self.data_path = data_path
+        self.labels_path = labels_path
+        self.include_all_data = include_all_data
+
         self._info = None
-        self._images_map = None
+        self._image_paths_map = None
+        self._cvat_images_map = None
         self._filenames = None
         self._iter_filenames = None
         self._num_samples = None
@@ -203,9 +246,12 @@ def __len__(self):
     def __next__(self):
         filename = next(self._iter_filenames)
 
-        image_path = os.path.join(self._data_dir, filename)
+        if os.path.isabs(filename):
+            image_path = filename
+        else:
+            image_path = self._image_paths_map[filename]
 
-        cvat_image = self._images_map.get(filename, None)
+        cvat_image = self._cvat_images_map.get(filename, None)
         if cvat_image is not None:
             # Labeled image
             image_metadata = cvat_image.get_image_metadata()
@@ -234,43 +280,69 @@ def label_cls(self):
         }
 
     def setup(self):
-        self._data_dir = os.path.join(self.dataset_dir, "data")
-        self._labels_path = os.path.join(self.dataset_dir, "labels.xml")
+        self._image_paths_map = self._load_data_map(self.data_path)
 
-        if os.path.isfile(self._labels_path):
+        if self.labels_path is not None and os.path.isfile(self.labels_path):
             info, _, cvat_images = load_cvat_image_annotations(
-                self._labels_path
+                self.labels_path
             )
         else:
             info = {}
             cvat_images = []
 
         self._info = info
+        self._cvat_images_map = {i.name: i for i in cvat_images}
 
-        # Index by filename
-        self._images_map = {i.name: i for i in cvat_images}
-
-        filenames = etau.list_files(self._data_dir, abs_paths=False)
+        filenames = set(self._cvat_images_map.keys())
 
-        if self.skip_unlabeled:
-            filenames = [f for f in filenames if f in self._images_map]
+        if self.include_all_data:
+            filenames.update(self._image_paths_map.keys())
 
-        self._filenames = self._preprocess_list(filenames)
+        self._filenames = self._preprocess_list(sorted(filenames))
         self._num_samples = len(self._filenames)
 
     def get_dataset_info(self):
         return self._info
 
 
-class CVATVideoDatasetImporter(foud.LabeledVideoDatasetImporter):
+class CVATVideoDatasetImporter(
+    foud.LabeledVideoDatasetImporter, foud.ImportPathsMixin
+):
     """Importer for CVAT video datasets stored on disk.
 
     See :class:`fiftyone.types.dataset_types.CVATVideoDataset` for format
     details.
 
     Args:
-        dataset_dir: the dataset directory
-        skip_unlabeled (False): whether to skip unlabeled videos when importing
+        dataset_dir (None): the dataset directory
+        data_path (None): an optional parameter that enables explicit control
+            over the location of the media. Can be any of the following:
+
+            -   a folder name like ``"data"`` or ``"data/"`` specifying a
+                subfolder of ``dataset_dir`` where the media files reside
+            -   an absolute directory path where the media files reside. In
+                this case, the ``dataset_dir`` has no effect on the location of
+                the data
+            -   a filename like ``"data.json"`` specifying the filename of the
+                JSON data manifest file in ``dataset_dir``
+            -   an absolute filepath specifying the location of the JSON data
+                manifest. In this case, ``dataset_dir`` has no effect on the
+                location of the data
+
+            If None, this parameter will default to whichever of ``data/`` or
+            ``data.json`` exists in the dataset directory
+        labels_path (None): an optional parameter that enables explicit control
+            over the location of the labels. Can be any of the following:
+
+            -   a folder name like ``"labels"`` or ``"labels/"`` specifying the
+                location of the labels in ``dataset_dir``
+            -   an absolute folder path to the labels. In this case,
+                ``dataset_dir`` has no effect on the location of the labels
+
+            If None, the parameter will default to ``labels/``
+        include_all_data (False): whether to generate samples for all videos in
+            the data directory (True) rather than only creating samples for
+            videos with label entries (False)
         shuffle (False): whether to randomly shuffle the order in which the
             samples are imported
         seed (None): a random seed to use when shuffling
@@ -280,23 +352,39 @@ class CVATVideoDatasetImporter(foud.LabeledVideoDatasetImporter):
 
     def __init__(
         self,
-        dataset_dir,
-        skip_unlabeled=False,
+        dataset_dir=None,
+        data_path=None,
+        labels_path=None,
+        include_all_data=False,
         shuffle=False,
         seed=None,
         max_samples=None,
     ):
+        data_path = self._parse_data_path(
+            dataset_dir=dataset_dir, data_path=data_path, default="data/",
+        )
+
+        labels_path = self._parse_labels_path(
+            dataset_dir=dataset_dir,
+            labels_path=labels_path,
+            default="labels/",
+        )
+
         super().__init__(
-            dataset_dir,
-            skip_unlabeled=skip_unlabeled,
+            dataset_dir=dataset_dir,
             shuffle=shuffle,
             seed=seed,
             max_samples=max_samples,
         )
+
+        self.data_path = data_path
+        self.labels_path = labels_path
+        self.include_all_data = include_all_data
+
         self._info = None
         self._cvat_task_labels = None
-        self._uuids_to_video_paths = None
-        self._uuids_to_labels_paths = None
+        self._video_paths_map = None
+        self._labels_paths_map = None
         self._uuids = None
         self._iter_uuids = None
         self._num_samples = None
@@ -311,9 +399,9 @@ def __len__(self):
     def __next__(self):
         uuid = next(self._iter_uuids)
 
-        video_path = self._uuids_to_video_paths[uuid]
+        video_path = self._video_paths_map[uuid]
 
-        labels_path = self._uuids_to_labels_paths.get(uuid, None)
+        labels_path = self._labels_paths_map.get(uuid, None)
         if labels_path:
             # Labeled video
             info, cvat_task_labels, cvat_tracks = load_cvat_video_annotations(
@@ -354,33 +442,23 @@ def frame_labels_cls(self):
         }
 
     def setup(self):
-        to_uuid = lambda p: os.path.splitext(os.path.basename(p))[0]
+        self._video_paths_map = self._load_data_map(self.data_path)
 
-        data_dir = os.path.join(self.dataset_dir, "data")
-        if os.path.isdir(data_dir):
-            self._uuids_to_video_paths = {
-                to_uuid(p): p
-                for p in etau.list_files(data_dir, abs_paths=True)
+        if self.labels_path is not None and os.path.isdir(self.labels_path):
+            self._labels_paths_map = {
+                os.path.splitext(os.path.basename(p))[0]: p
+                for p in etau.list_files(self.labels_path, abs_paths=True)
             }
         else:
-            self._uuids_to_video_paths = {}
+            self._labels_paths_map = {}
 
-        labels_dir = os.path.join(self.dataset_dir, "labels")
-        if os.path.isdir(labels_dir):
-            self._uuids_to_labels_paths = {
-                to_uuid(p): p
-                for p in etau.list_files(labels_dir, abs_paths=True)
-            }
-        else:
-            self._uuids_to_labels_paths = {}
+        uuids = set(self._labels_paths_map.keys())
 
-        if self.skip_unlabeled:
-            uuids = sorted(self._uuids_to_labels_paths.keys())
-        else:
-            uuids = sorted(self._uuids_to_video_paths.keys())
+        if self.include_all_data:
+            uuids.update(self._video_paths_map.keys())
 
         self._info = None
-        self._uuids = self._preprocess_list(uuids)
+        self._uuids = self._preprocess_list(sorted(uuids))
         self._num_samples = len(self._uuids)
         self._cvat_task_labels = CVATTaskLabels()
 
@@ -388,31 +466,98 @@ def get_dataset_info(self):
         return self._info
 
 
-class CVATImageDatasetExporter(foud.LabeledImageDatasetExporter):
+class CVATImageDatasetExporter(
+    foud.LabeledImageDatasetExporter, foud.ExportPathsMixin
+):
     """Exporter that writes CVAT image datasets to disk.
 
     See :class:`fiftyone.types.dataset_types.CVATImageDataset` for format
     details.
 
     Args:
-        export_dir: the directory to write the export
+        export_dir (None): the directory to write the export. This has no
+            effect if ``data_path`` and ``labels_path`` are absolute paths
+        data_path (None): an optional parameter that enables explicit control
+            over the location of the exported media. Can be any of the
+            following:
+
+            -   a folder name like ``"data"`` or ``"data/"`` specifying a
+                subfolder of ``export_dir`` in which to export the media
+            -   an absolute directory path in which to export the media. In
+                this case, the ``export_dir`` has no effect on the location of
+                the data
+            -   a JSON filename like ``"data.json"`` specifying the filename of
+                the manifest file in ``export_dir`` generated when
+                ``export_media`` is ``"manifest"``
+            -   an absolute filepath specifying the location to write the JSON
+                manifest file when ``export_media`` is ``"manifest"``. In this
+                case, ``export_dir`` has no effect on the location of the data
+
+            If None, the default value of this parameter will be chosen based
+            on the value of the ``export_media`` parameter
+        labels_path (None): an optional parameter that enables explicit control
+            over the location of the exported labels. Can be any of the
+            following:
+
+            -   a filename like ``"labels.xml"`` specifying the location in
+                ``export_dir`` in which to export the labels
+            -   an absolute filepath to which to export the labels. In this
+                case, the ``export_dir`` has no effect on the location of the
+                labels
+
+            If None, the labels will be exported into ``export_dir`` using the
+            default filename
+        export_media (None): controls how to export the raw media. The
+            supported values are:
+
+            -   ``True``: copy all media files into the output directory
+            -   ``False``: don't export media
+            -   ``"move"``: move all media files into the output directory
+            -   ``"symlink"``: create symlinks to the media files in the output
+                directory
+            -   ``"manifest"``: create a ``data.json`` in the output directory
+                that maps UUIDs used in the labels files to the filepaths of
+                the source media, rather than exporting the actual media
+
+            If None, the default value of this parameter will be chosen based
+            on the value of the ``data_path`` parameter
         image_format (None): the image format to use when writing in-memory
             images to disk. By default, ``fiftyone.config.default_image_ext``
             is used
     """
 
-    def __init__(self, export_dir, image_format=None):
-        if image_format is None:
-            image_format = fo.config.default_image_ext
+    def __init__(
+        self,
+        export_dir=None,
+        data_path=None,
+        labels_path=None,
+        export_media=None,
+        image_format=None,
+    ):
+        data_path, export_media = self._parse_data_path(
+            export_dir=export_dir,
+            data_path=data_path,
+            export_media=export_media,
+            default="data/",
+        )
 
-        super().__init__(export_dir)
+        labels_path = self._parse_labels_path(
+            export_dir=export_dir,
+            labels_path=labels_path,
+            default="labels.xml",
+        )
+
+        super().__init__(export_dir=export_dir)
+
+        self.data_path = data_path
+        self.labels_path = labels_path
+        self.export_media = export_media
         self.image_format = image_format
+
         self._name = None
         self._task_labels = None
-        self._data_dir = None
-        self._labels_path = None
         self._cvat_images = None
-        self._filename_maker = None
+        self._media_exporter = None
 
     @property
     def requires_image_metadata(self):
@@ -427,21 +572,20 @@ def label_cls(self):
         }
 
     def setup(self):
-        self._data_dir = os.path.join(self.export_dir, "data")
-        self._labels_path = os.path.join(self.export_dir, "labels.xml")
         self._cvat_images = []
-        self._filename_maker = fou.UniqueFilenameMaker(
-            output_dir=self._data_dir, default_ext=self.image_format
+        self._media_exporter = foud.ImageExporter(
+            self.export_media,
+            export_path=self.data_path,
+            default_ext=self.image_format,
         )
+        self._media_exporter.setup()
 
     def log_collection(self, sample_collection):
         self._name = sample_collection.name
         self._task_labels = sample_collection.info.get("task_labels", None)
 
     def export_sample(self, image_or_path, labels, metadata=None):
-        out_image_path = self._export_image_or_path(
-            image_or_path, self._filename_maker
-        )
+        out_image_path, _ = self._media_exporter.export(image_or_path)
 
         if labels is None:
             return  # unlabeled
@@ -478,30 +622,99 @@ def close(self, *args):
         writer.write(
             cvat_task_labels,
             self._cvat_images,
-            self._labels_path,
+            self.labels_path,
             id=0,
             name=self._name,
         )
 
+        self._media_exporter.close()
+
 
-class CVATVideoDatasetExporter(foud.LabeledVideoDatasetExporter):
+class CVATVideoDatasetExporter(
+    foud.LabeledVideoDatasetExporter, foud.ExportPathsMixin
+):
     """Exporter that writes CVAT video datasets to disk.
 
     See :class:`fiftyone.types.dataset_types.CVATVideoDataset` for format
     details.
 
     Args:
-        export_dir: the directory to write the export
+        export_dir (None): the directory to write the export. This has no
+            effect if ``data_path`` and ``labels_path`` are absolute paths
+        data_path (None): an optional parameter that enables explicit control
+            over the location of the exported media. Can be any of the
+            following:
+
+            -   a folder name like ``"data"`` or ``"data/"`` specifying a
+                subfolder of ``export_dir`` in which to export the media
+            -   an absolute directory path in which to export the media. In
+                this case, the ``export_dir`` has no effect on the location of
+                the data
+            -   a JSON filename like ``"data.json"`` specifying the filename of
+                the manifest file in ``export_dir`` generated when
+                ``export_media`` is ``"manifest"``
+            -   an absolute filepath specifying the location to write the JSON
+                manifest file when ``export_media`` is ``"manifest"``. In this
+                case, ``export_dir`` has no effect on the location of the data
+
+            If None, the default value of this parameter will be chosen based
+            on the value of the ``export_media`` parameter
+        labels_path (None): an optional parameter that enables explicit control
+            over the location of the exported labels. Can be any of the
+            following:
+
+            -   a folder name like ``"labels"`` or ``"labels/"`` specifying the
+                location in ``export_dir`` in which to export the labels
+            -   an absolute filepath to which to export the labels. In this
+                case, the ``export_dir`` has no effect on the location of the
+                labels
+
+            If None, the labels will be exported into ``export_dir`` using the
+            default folder name
+        export_media (None): controls how to export the raw media. The
+            supported values are:
+
+            -   ``True``: copy all media files into the output directory
+            -   ``False``: don't export media
+            -   ``"move"``: move all media files into the output directory
+            -   ``"symlink"``: create symlinks to the media files in the output
+                directory
+            -   ``"manifest"``: create a ``data.json`` in the output directory
+                that maps UUIDs used in the labels files to the filepaths of
+                the source media, rather than exporting the actual media
+
+            If None, the default value of this parameter will be chosen based
+            on the value of the ``data_path`` parameter
     """
 
-    def __init__(self, export_dir):
-        super().__init__(export_dir)
+    def __init__(
+        self,
+        export_dir=None,
+        data_path=None,
+        labels_path=None,
+        export_media=None,
+    ):
+        data_path, export_media = self._parse_data_path(
+            export_dir=export_dir,
+            data_path=data_path,
+            export_media=export_media,
+            default="data/",
+        )
+
+        labels_path = self._parse_labels_path(
+            export_dir=export_dir, labels_path=labels_path, default="labels/",
+        )
+
+        super().__init__(export_dir=export_dir)
+
+        self.data_path = data_path
+        self.labels_path = labels_path
+        self.export_media = export_media
+
         self._task_labels = None
-        self._data_dir = None
-        self._labels_dir = None
-        self._filename_maker = None
-        self._writer = None
         self._num_samples = 0
+        self._writer = None
+        self._media_exporter = None
 
     @property
     def requires_video_metadata(self):
@@ -520,21 +733,17 @@ def frame_labels_cls(self):
         }
 
     def setup(self):
-        self._data_dir = os.path.join(self.export_dir, "data")
-        self._labels_dir = os.path.join(self.export_dir, "labels")
-        self._filename_maker = fou.UniqueFilenameMaker(
-            output_dir=self._data_dir
-        )
         self._writer = CVATVideoAnnotationWriter()
-
-        etau.ensure_dir(self._data_dir)
-        etau.ensure_dir(self._labels_dir)
+        self._media_exporter = foud.ImageExporter(
+            self.export_media, export_path=self.data_path,
+        )
+        self._media_exporter.setup()
 
     def log_collection(self, sample_collection):
         self._task_labels = sample_collection.info.get("task_labels", None)
 
     def export_sample(self, video_path, _, frames, metadata=None):
-        out_video_path = self._export_video(video_path, self._filename_maker)
+        out_video_path, _ = self._media_exporter.export(video_path)
 
         if frames is None:
             return  # unlabeled
@@ -544,7 +753,7 @@ def export_sample(self, video_path, _, frames, metadata=None):
 
         name_with_ext = os.path.basename(out_video_path)
         name = os.path.splitext(name_with_ext)[0]
-        out_anno_path = os.path.join(self._labels_dir, name + ".xml")
+        out_anno_path = os.path.join(self.labels_path, name + ".xml")
 
         # Generate object tracks
         frame_size = (metadata.frame_width, metadata.frame_height)
@@ -572,6 +781,9 @@ def export_sample(self, video_path, _, frames, metadata=None):
             name=name_with_ext,
         )
 
+    def close(self, *args):
+        self._media_exporter.close()
+
 
 class CVATTaskLabels(object):
     """Description of the labels in a CVAT image annotation task.
diff --git a/fiftyone/utils/data/exporters.py b/fiftyone/utils/data/exporters.py
index 4fa9edb4f4a..d083b05bff2 100644
--- a/fiftyone/utils/data/exporters.py
+++ b/fiftyone/utils/data/exporters.py
@@ -45,16 +45,41 @@ def export_samples(
     samples,
     export_dir=None,
     dataset_type=None,
+    data_path=None,
+    labels_path=None,
+    export_media=None,
     dataset_exporter=None,
     label_field_or_dict=None,
     frame_labels_field_or_dict=None,
     num_samples=None,
-    **kwargs
+    **kwargs,
 ):
-    """Exports the given samples to disk as a dataset in the specified format.
+    """Exports the given samples to disk.
 
-    Provide either ``export_dir`` and ``dataset_type`` or ``dataset_exporter``
-    to perform the export.
+    You can perform exports with this method via the following basic patterns:
+
+    (a) Provide ``export_dir`` and ``dataset_type`` to export the content to a
+        directory in the default layout for the specified format, as documented
+        in :ref:`this page <exporting-datasets>`
+
+    (b) Provide ``dataset_type`` along with ``data_path``, ``labels_path``,
+        and/or ``export_media`` to directly specify where to export the source
+        media and/or labels (if applicable) in your desired format. This syntax
+        provides the flexibility to, for example, perform workflows like
+        labels-only exports
+
+    (c) Provide a ``dataset_exporter`` to which to feed samples to perform a
+        fully-customized export
+
+    In all workflows, the remaining parameters of this method can be provided
+    to further configure the export.
+
+    See :ref:`this page <exporting-datasets>` for more information about the
+    available export formats and examples of using this method.
+
+    See :ref:`this guide <custom-dataset-exporter>` for more details about
+    exporting datasets in custom formats by defining your own
+    :class:`fiftyone.utils.data.exporters.DatasetExporter`.
 
     This method will automatically coerce the data to match the requested
     export in the following cases:
@@ -88,9 +113,63 @@ def export_samples(
             format ``dataset_type``
         dataset_type (None): the :class:`fiftyone.types.dataset_types.Dataset`
             type to write
-        dataset_exporter (None): a
-            :class:`fiftyone.utils.data.exporters.DatasetExporter` to use to
-            write the dataset
+        data_path (None): an optional parameter that enables explicit control
+            over the location of the exported media for certain export formats.
+            Can be any of the following:
+
+            -   a folder name like ``"data"`` or ``"data/"`` specifying a
+                subfolder of ``export_dir`` in which to export the media
+            -   an absolute directory path in which to export the media. In
+                this case, the ``export_dir`` has no effect on the location of
+                the data
+            -   a filename like ``"data.json"`` specifying the filename of a
+                JSON manifest file in ``export_dir`` generated when
+                ``export_media`` is ``"manifest"``
+            -   an absolute filepath specifying the location to write the JSON
+                manifest file when ``export_media`` is ``"manifest"``. In this
+                case, ``export_dir`` has no effect on the location of the data
+
+            If None, a default value of this parameter will be chosen based on
+            the value of the ``export_media`` parameter. Note that this
+            parameter is not applicable to certain export formats such as
+            binary types like TF records
+        labels_path (None): an optional parameter that enables explicit control
+            over the location of the exported labels. Only applicable when
+            exporting in certain labeled dataset formats. Can be any of the
+            following:
+
+            -   a type-specific folder name like ``"labels"`` or ``"labels/"``
+                or a filename like ``"labels.json"`` or ``"labels.xml"``
+                specifying the location in ``export_dir`` in which to export
+                the labels
+            -   an absolute directory or filepath in which to export the
+                labels. In this case, the ``export_dir`` has no effect on the
+                location of the labels
+
+            For labeled datasets, the default value of this parameter will be
+            chosen based on the export format so that the labels will be
+            exported into ``export_dir``
+        export_media (None): controls how to export the raw media. The
+            supported values are:
+
+            -   ``True``: copy all media files into the output directory
+            -   ``False``: don't export media. This option is only useful when
+                exporting labeled datasets whose label format stores sufficient
+                information to locate the associated media
+            -   ``"move"``: move all media files into the output directory
+            -   ``"symlink"``: create symlinks to the media files in the output
+                directory
+            -   ``"manifest"``: create a ``data.json`` in the output directory
+                that maps UUIDs used in the labels files to the filepaths of
+                the source media, rather than exporting the actual media
+
+            If None, an appropriate default value of this parameter will be
+            chosen based on the value of the ``data_path`` parameter. Note that
+            some dataset formats may not support certain values for this
+            parameter (e.g., when exporting in binary formats such as TF
+            records, "symlink" is not an option)
+        dataset_exporter (None): a :class:`DatasetExporter` to use to write the
+            dataset
         label_field_or_dict (None): the name of the label field to export, or
             a dictionary mapping field names to output keys describing the
             label fields to export. Only applicable if ``dataset_exporter`` is
@@ -104,18 +183,38 @@ def export_samples(
         num_samples (None): the number of samples in ``samples``. If omitted,
             this is computed (if possible) via ``len(samples)``
         **kwargs: optional keyword arguments to pass to the dataset exporter's
-            constructor via ``DatasetExporter(export_dir, **kwargs)``. If you
-            are exporting image patches, this can also contain keyword
-            arguments for :class:`fiftyone.utils.patches.ImagePatchesExtractor`
+            constructor. If you are exporting image patches, this can also
+            contain keyword arguments for
+            :class:`fiftyone.utils.patches.ImagePatchesExtractor`
     """
     found_patches, patches_kwargs, kwargs = _check_for_patches_export(
         samples, dataset_exporter, label_field_or_dict, kwargs
     )
 
+    if dataset_exporter is None:
+        dataset_exporter, _ = build_dataset_exporter(
+            dataset_type,
+            export_dir=export_dir,
+            data_path=data_path,
+            labels_path=labels_path,
+            export_media=export_media,
+            **kwargs,
+        )
+    else:
+        kwargs.update(
+            dict(
+                export_dir=export_dir,
+                data_path=data_path,
+                labels_path=labels_path,
+                export_media=export_media,
+            )
+        )
+
+        for key, value in kwargs.items():
+            if value is not None:
+                logger.warning("Ignoring unsupported parameter '%s'", key)
+
     sample_collection = samples
-    dataset_exporter = _get_dataset_exporter(
-        export_dir, dataset_type, dataset_exporter, **kwargs
-    )
 
     if isinstance(dataset_exporter, BatchDatasetExporter):
         _write_batch_dataset(dataset_exporter, samples)
@@ -191,7 +290,7 @@ def export_samples(
     write_dataset(
         samples,
         sample_parser,
-        dataset_exporter=dataset_exporter,
+        dataset_exporter,
         num_samples=num_samples,
         sample_collection=sample_collection,
     )
@@ -200,29 +299,18 @@ def export_samples(
 def write_dataset(
     samples,
     sample_parser,
-    dataset_dir=None,
-    dataset_type=None,
-    dataset_exporter=None,
+    dataset_exporter,
     num_samples=None,
     sample_collection=None,
-    **kwargs
 ):
     """Writes the samples to disk as a dataset in the specified format.
 
-    Provide either ``dataset_dir`` and ``dataset_type`` or ``dataset_exporter``
-    to perform the write.
-
     Args:
         samples: an iterable of samples that can be parsed by ``sample_parser``
         sample_parser: a :class:`fiftyone.utils.data.parsers.SampleParser` to
             use to parse the samples
-        dataset_dir (None): the directory to which to write the dataset in
-            format ``dataset_type``
-        dataset_type (None): the :class:`fiftyone.types.dataset_types.Dataset`
-            type to write
-        dataset_exporter (None): a
-            :class:`fiftyone.utils.data.exporters.DatasetExporter` to use to
-            write the dataset
+        dataset_exporter: a :class:`DatasetExporter` to use to write the
+            dataset
         num_samples (None): the number of samples in ``samples``. If omitted,
             this is computed (if possible) via ``len(samples)``
         sample_collection (None): the
@@ -231,13 +319,7 @@ def write_dataset(
             :class:`fiftyone.core.collections.SampleCollection`, this parameter
             defaults to ``samples``. This parameter is optional and is only
             passed to :meth:`DatasetExporter.log_collection`
-        **kwargs: optional keyword arguments to pass to
-            ``dataset_type.get_dataset_exporter_cls(dataset_dir, **kwargs)``
     """
-    dataset_exporter = _get_dataset_exporter(
-        dataset_dir, dataset_type, dataset_exporter, **kwargs
-    )
-
     if num_samples is None:
         try:
             num_samples = len(samples)
@@ -282,6 +364,66 @@ def write_dataset(
         )
 
 
+def build_dataset_exporter(
+    dataset_type, strip_none=True, warn_unused=True, **kwargs
+):
+    """Builds the :class:`DatasetExporter` instance for the given parameters.
+
+    Args:
+        dataset_type: the :class:`fiftyone.types.dataset_types.Dataset` type
+        strip_none (True): whether to exclude None-valued items from ``kwargs``
+        warn_unused (True): whether to issue warnings for any non-None unused
+            parameters encountered
+        **kwargs: keyword arguments to pass to the dataset exporter's
+            constructor via ``DatasetExporter(**kwargs)``
+
+    Returns:
+        a tuple of:
+
+        -   the :class:`DatasetExporter` instance
+        -   a dict of unused keyword arguments
+    """
+    if dataset_type is None:
+        raise ValueError(
+            "You must provide a `dataset_type` in order to build a dataset "
+            "exporter"
+        )
+
+    if inspect.isclass(dataset_type):
+        dataset_type = dataset_type()
+
+    dataset_exporter_cls = dataset_type.get_dataset_exporter_cls()
+
+    if strip_none:
+        kwargs = {k: v for k, v in kwargs.items() if v is not None}
+
+    kwargs, unused_kwargs = fou.extract_kwargs_for_class(
+        dataset_exporter_cls, kwargs
+    )
+
+    try:
+        dataset_exporter = dataset_exporter_cls(**kwargs)
+    except Exception as e:
+        raise ValueError(
+            "Failed to construct exporter of type %s using the provided "
+            "parameters. See above for the error. You may need to supply "
+            "additional mandatory arguments. Please consult the documentation "
+            "of %s to learn more"
+            % (dataset_exporter_cls, dataset_exporter_cls)
+        ) from e
+
+    if warn_unused:
+        for key, value in unused_kwargs.items():
+            if value is not None:
+                logger.warning(
+                    "Ignoring unsupported parameter '%s' for exporter type %s",
+                    key,
+                    dataset_exporter_cls,
+                )
+
+    return dataset_exporter, unused_kwargs
+
+
 def _check_for_patches_export(
     samples, dataset_exporter, label_field_or_dict, kwargs
 ):
@@ -384,10 +526,8 @@ def _make_label_coersion_functions(
         #
 
         for export_type in export_types:
-            single_label_type = fol._LABEL_LIST_TO_SINGLE_MAP.get(
-                export_type, None
-            )
-            if issubclass(label_type, single_label_type):
+            single_type = fol._LABEL_LIST_TO_SINGLE_MAP.get(export_type, None)
+            if single_type is not None and issubclass(label_type, single_type):
                 logger.info(
                     "Dataset exporter expects labels in %s format, but found "
                     "%s. Wrapping field '%s' as single-label lists...",
@@ -457,33 +597,6 @@ def _classification_to_detections(label):
     )
 
 
-def _get_dataset_exporter(
-    export_dir, dataset_type, dataset_exporter, **kwargs
-):
-    if dataset_type is not None:
-        if inspect.isclass(dataset_type):
-            dataset_type = dataset_type()
-
-        if not isinstance(
-            dataset_type,
-            (
-                fot.UnlabeledImageDataset,
-                fot.LabeledImageDataset,
-                fot.UnlabeledVideoDataset,
-                fot.LabeledVideoDataset,
-            ),
-        ):
-            raise ValueError(
-                "Unsupported `dataset_type` %s" % type(dataset_type)
-            )
-
-    if dataset_exporter is None:
-        dataset_exporter_cls = dataset_type.get_dataset_exporter_cls()
-        dataset_exporter = dataset_exporter_cls(export_dir, **kwargs)
-
-    return dataset_exporter
-
-
 def _write_batch_dataset(dataset_exporter, samples):
     if not isinstance(samples, foc.SampleCollection):
         raise ValueError(
@@ -608,6 +721,250 @@ def _write_video_dataset(
                     )
 
 
+class ExportPathsMixin(object):
+    """Mixin for :class:`DatasetExporter` classes that provides convenience
+    methods for parsing the ``data_path``, ``labels_path``, and
+    ``export_media`` parameters supported by many exporters.
+    """
+
+    @staticmethod
+    def _parse_data_path(
+        export_dir=None, data_path=None, export_media=None, default=None,
+    ):
+        """Helper function that computes default values for the ``data_path``
+        and ``export_media`` parameters supported by many exporters.
+        """
+        if data_path is None:
+            if export_media == "manifest" and default is not None:
+                data_path = os.path.normpath(default) + ".json"
+            elif export_dir is not None:
+                data_path = default
+
+        if data_path is not None:
+            data_path = os.path.expanduser(data_path)
+
+            if not os.path.isabs(data_path) and export_dir is not None:
+                export_dir = os.path.abspath(os.path.expanduser(export_dir))
+                data_path = os.path.join(export_dir, data_path)
+
+        if export_media is None:
+            if data_path is None:
+                export_media = False
+            elif data_path.endswith(".json"):
+                export_media = "manifest"
+            else:
+                export_media = True
+
+        return data_path, export_media
+
+    @staticmethod
+    def _parse_labels_path(export_dir=None, labels_path=None, default=None):
+        """Helper function that computes default values for the ``labels_path``
+        parameter supported by many exporters.
+        """
+        if labels_path is None:
+            labels_path = default
+
+        if labels_path is not None:
+            labels_path = os.path.expanduser(labels_path)
+
+            if not os.path.isabs(labels_path) and export_dir is not None:
+                export_dir = os.path.abspath(os.path.expanduser(export_dir))
+                labels_path = os.path.join(export_dir, labels_path)
+
+        return labels_path
+
+
+class MediaExporter(object):
+    """Base class for :class:`DatasetExporter` utilities that provide support
+    for populating a directory or manifest of media files.
+
+    This class is designed for populating a single, flat directory or manifest
+    of media files, and automatically takes care of things like name clashes.
+
+    The export strategy used is defined by the ``export_mode`` parameter, and
+    users can restrict the available options via the ``supported_modes``
+    parameter.
+
+    Args:
+        export_mode: the export mode to use. The supported values are:
+
+            -   ``True``: copy all media files into the output directory
+            -   ``False``: don't export media. This option is only useful when
+                exporting labeled datasets whose label format stores sufficient
+                information to locate the associated media
+            -   ``"move"``: move all media files into the output directory
+            -   ``"symlink"``: create symlinks to the media files in the output
+                directory
+            -   ``"manifest"``: create a ``data.json`` in the output directory
+                that maps UUIDs used in the labels files to the filepaths of
+                the source media, rather than exporting the actual media
+        export_path (None): the location to export the media. Can be any of the
+            following:
+
+            -   When ``export_media`` is True, "move", or "symlink", a
+                directory in which to export the media
+            -   When ``export_mode`` is "manifest", the path to write a JSON
+                file mapping UUIDs to input filepaths
+            -   When ``export_media`` is False, this parameter has no effect
+        supported_modes (None): an optional tuple specifying a subset of the
+            ``export_mode`` values that are allowed
+        default_ext (None): the file extension to use when generating default
+            output paths
+        ignore_exts (False): whether to omit file extensions when generating
+            UUIDs for files
+    """
+
+    def __init__(
+        self,
+        export_mode,
+        export_path=None,
+        supported_modes=None,
+        default_ext=None,
+        ignore_exts=False,
+    ):
+        if supported_modes is None:
+            supported_modes = (True, False, "move", "symlink", "manifest")
+
+        if export_mode not in supported_modes:
+            raise ValueError(
+                "Unsupported media export mode `%s`. The supported values are "
+                "%s" % (export_mode, supported_modes)
+            )
+
+        if export_mode != False and export_path is None:
+            raise ValueError(
+                "An export path must be provided for export mode %s"
+                % export_mode
+            )
+
+        self.export_mode = export_mode
+        self.export_path = export_path
+        self.supported_modes = supported_modes
+        self.default_ext = default_ext
+        self.ignore_exts = ignore_exts
+
+        self._filename_maker = None
+        self._manifest = None
+        self._manifest_path = None
+
+    def _write_media(self, media, outpath):
+        raise NotImplementedError("subclass must implement _write_media()")
+
+    def _get_uuid(self, media_path):
+        filename = os.path.basename(media_path)
+        if self.ignore_exts:
+            return os.path.splitext(filename)[0]
+
+        return filename
+
+    def setup(self):
+        """Performs necessary setup to begin exporting media.
+
+        :class:`DatasetExporter` classes using this class should invoke this
+        method in :meth:`DatasetExporter.setup`.
+        """
+        output_dir = None
+        manifest_path = None
+        manifest = None
+
+        if self.export_mode in {True, False, "move", "symlink"}:
+            output_dir = self.export_path
+        elif self.export_mode == "manifest":
+            manifest_path = self.export_path
+            manifest = {}
+
+        self._filename_maker = fou.UniqueFilenameMaker(
+            output_dir=output_dir,
+            default_ext=self.default_ext,
+            ignore_exts=self.ignore_exts,
+        )
+        self._manifest_path = manifest_path
+        self._manifest = manifest
+
+    def export(self, media_or_path):
+        """Exports the given media.
+
+        Args:
+            media_or_path: the media or path to the media on disk
+
+        Returns:
+            a tuple of:
+
+            -   the path to the exported media
+            -   the UUID of the exported media
+        """
+        if etau.is_str(media_or_path):
+            media_path = media_or_path
+            outpath = self._filename_maker.get_output_path(media_path)
+            uuid = self._get_uuid(outpath)
+
+            if self.export_mode == True:
+                etau.copy_file(media_path, outpath)
+            if self.export_mode == "move":
+                etau.move_file(media_path, outpath)
+            elif self.export_mode == "symlink":
+                etau.symlink_file(media_path, outpath)
+            elif self.export_mode == "manifest":
+                outpath = None
+                self._manifest[uuid] = media_path
+        else:
+            media = media_or_path
+            outpath = self._filename_maker.get_output_path()
+            uuid = self._get_uuid(outpath)
+
+            if self.export_mode == True:
+                self._write_media(media, outpath)
+            elif self.export_mode != False:
+                raise ValueError(
+                    "Cannot export in-memory media when 'export_mode=%s'"
+                    % self.export_mode
+                )
+
+        return outpath, uuid
+
+    def close(self):
+        """Performs any necessary actions to complete an export.
+
+        :class:`DatasetExporter` classes implementing this mixin should invoke
+        this method in :meth:`DatasetExporter.close`.
+        """
+        if self.export_mode == "manifest":
+            etas.write_json(self._manifest, self._manifest_path)
+
+
+class ImageExporter(MediaExporter):
+    """Utility class for :class:`DatasetExporter` instances that export images.
+
+    See :class:`MediaExporter` for details.
+    """
+
+    def __init__(self, *args, default_ext=None, **kwargs):
+        if default_ext is None:
+            default_ext = fo.config.default_image_ext
+
+        super().__init__(*args, default_ext=default_ext, **kwargs)
+
+    def _write_media(self, img, outpath):
+        etai.write(img, outpath)
+
+
+class VideoExporter(MediaExporter):
+    """Utility class for :class:`DatasetExporter` instances that export videos.
+
+    See :class:`MediaExporter` for details.
+    """
+
+    def __init__(self, *args, default_ext=None, **kwargs):
+        if default_ext is None:
+            default_ext = fo.config.default_video_ext
+
+        super().__init__(*args, default_ext=default_ext, **kwargs)
+
+    def _write_media(self, media, outpath):
+        raise ValueError("Only video paths can be exported")
+
+
 class DatasetExporter(object):
     """Base interface for exporting datsets.
 
@@ -615,11 +972,15 @@ class DatasetExporter(object):
     for information about implementing/using dataset exporters.
 
     Args:
-        export_dir: the directory to write the export
+        export_dir (None): the directory to write the export. This may be
+            optional for some exporters
     """
 
-    def __init__(self, export_dir):
-        self.export_dir = os.path.abspath(os.path.expanduser(export_dir))
+    def __init__(self, export_dir=None):
+        if export_dir is not None:
+            export_dir = os.path.abspath(os.path.expanduser(export_dir))
+
+        self.export_dir = export_dir
 
     def __enter__(self):
         self.setup()
@@ -693,13 +1054,14 @@ class BatchDatasetExporter(DatasetExporter):
     handle aggregating over the samples themselves.
 
     Args:
-        export_dir: the directory to write the export
+        export_dir (None): the directory to write the export. This may be
+            optional for some exporters
     """
 
     def export_sample(self, *args, **kwargs):
         raise ValueError(
             "Use export_samples() to perform exports with %s instances"
-            % self.__class__
+            % type(self)
         )
 
     def export_samples(self, sample_collection):
@@ -712,70 +1074,6 @@ def export_samples(self, sample_collection):
         raise NotImplementedError("subclass must implement export_samples()")
 
 
-class ExportsImages(object):
-    """Mixin for :class:`DatasetExporter` classes that export images."""
-
-    @staticmethod
-    def _is_image_path(image_or_path):
-        """Determines whether the input is the path to an image on disk
-
-        Args:
-            image_or_path: an image or the path to the image on disk
-
-        Returns:
-            True/False
-        """
-        return etau.is_str(image_or_path)
-
-    @staticmethod
-    def _export_image_or_path(image_or_path, filename_maker):
-        """Exports the image, using the given
-        :class:`fiftyone.core.utils.UniqueFilenameMaker` to generate the output
-        path for the image.
-
-        Args:
-            image_or_path: an image or the path to the image on disk
-            filename_maker: a :class:`fiftyone.core.utils.UniqueFilenameMaker`
-                to use to generate the output image path
-
-        Returns:
-            the path to the exported image
-        """
-        if ExportsImages._is_image_path(image_or_path):
-            image_path = image_or_path
-            out_image_path = filename_maker.get_output_path(image_path)
-            etau.copy_file(image_path, out_image_path)
-        else:
-            img = image_or_path
-            out_image_path = filename_maker.get_output_path()
-            etai.write(img, out_image_path)
-
-        return out_image_path
-
-
-class ExportsVideos(object):
-    """Mixin for :class:`DatasetExporter` classes that export videos."""
-
-    @staticmethod
-    def _export_video(video_path, filename_maker):
-        """Exports the video, using the given
-        :class:`fiftyone.core.utils.UniqueFilenameMaker` to generate the output
-        path for the video.
-
-        Args:
-            video_path: the path to a video on disk
-            filename_maker: a :class:`fiftyone.core.utils.UniqueFilenameMaker`
-                to use to generate the output video path
-
-        Returns:
-            the path to the exported video
-        """
-        out_video_path = filename_maker.get_output_path(video_path)
-        etau.copy_file(video_path, out_video_path)
-
-        return out_video_path
-
-
 class GenericSampleDatasetExporter(DatasetExporter):
     """Interface for exporting datasets of arbitrary
     :class:`fiftyone.core.sample.Sample` instances.
@@ -784,7 +1082,8 @@ class GenericSampleDatasetExporter(DatasetExporter):
     for information about implementing/using dataset exporters.
 
     Args:
-        export_dir: the directory to write the export
+        export_dir (None): the directory to write the export. This may be
+            optional for some exporters
     """
 
     def export_sample(self, sample):
@@ -796,14 +1095,15 @@ def export_sample(self, sample):
         raise NotImplementedError("subclass must implement export_sample()")
 
 
-class UnlabeledImageDatasetExporter(DatasetExporter, ExportsImages):
+class UnlabeledImageDatasetExporter(DatasetExporter):
     """Interface for exporting datasets of unlabeled image samples.
 
     See `this page <https://voxel51.com/docs/fiftyone/user_guide/export_datasets.html#writing-a-custom-datasetexporter>`_
     for information about implementing/using dataset exporters.
 
     Args:
-        export_dir: the directory to write the export
+        export_dir (None): the directory to write the export. This may be
+            optional for some exporters
     """
 
     @property
@@ -828,14 +1128,15 @@ def export_sample(self, image_or_path, metadata=None):
         raise NotImplementedError("subclass must implement export_sample()")
 
 
-class UnlabeledVideoDatasetExporter(DatasetExporter, ExportsVideos):
+class UnlabeledVideoDatasetExporter(DatasetExporter):
     """Interface for exporting datasets of unlabeled video samples.
 
     See `this page <https://voxel51.com/docs/fiftyone/user_guide/export_datasets.html#writing-a-custom-datasetexporter>`_
     for information about implementing/using dataset exporters.
 
     Args:
-        export_dir: the directory to write the export
+        export_dir (None): the directory to write the export. This may be
+            optional for some exporters
     """
 
     @property
@@ -860,14 +1161,15 @@ def export_sample(self, video_path, metadata=None):
         raise NotImplementedError("subclass must implement export_sample()")
 
 
-class LabeledImageDatasetExporter(DatasetExporter, ExportsImages):
+class LabeledImageDatasetExporter(DatasetExporter):
     """Interface for exporting datasets of labeled image samples.
 
     See `this page <https://voxel51.com/docs/fiftyone/user_guide/export_datasets.html#writing-a-custom-datasetexporter>`_
     for information about implementing/using dataset exporters.
 
     Args:
-        export_dir: the directory to write the export
+        export_dir (None): the directory to write the export. This may be
+            optional for some exporters
     """
 
     @property
@@ -913,14 +1215,15 @@ def export_sample(self, image_or_path, label, metadata=None):
         raise NotImplementedError("subclass must implement export_sample()")
 
 
-class LabeledVideoDatasetExporter(DatasetExporter, ExportsVideos):
+class LabeledVideoDatasetExporter(DatasetExporter):
     """Interface for exporting datasets of labeled video samples.
 
     See `this page <https://voxel51.com/docs/fiftyone/user_guide/export_datasets.html#writing-a-custom-datasetexporter>`_
     for information about implementing/using dataset exporters.
 
     Args:
-        export_dir: the directory to write the export
+        export_dir (None): the directory to write the export. This may be
+            optional for some exporters
     """
 
     @property
@@ -999,12 +1302,16 @@ class LegacyFiftyOneDatasetExporter(GenericSampleDatasetExporter):
 
     Args:
         export_dir: the directory to write the export
-        export_media (True): whether to export media files using the method
-            defined by ``move_media`` (True), or to export only labels and
-            metadata (False)
-        move_media (False): whether to move (True) or copy (False) the source
-            media into its output destination. Only applicable if
-            ``export_media`` is True
+        export_media (None): defines how to export the raw media contained
+            in the dataset. The supported values are:
+
+            -   ``True`` (default): copy all media files into the export
+                directory
+            -   ``False``: don't export media
+            -   ``"move"``: move media files into the export directory
+            -   ``"symlink"``: create symlinks to each media file in the export
+                directory
+
         relative_filepaths (True): whether to store relative (True) or absolute
             (False) filepaths to media files on disk in the output dataset
         pretty_print (False): whether to render the JSON in human readable
@@ -1014,16 +1321,19 @@ class LegacyFiftyOneDatasetExporter(GenericSampleDatasetExporter):
     def __init__(
         self,
         export_dir,
-        export_media=True,
-        move_media=False,
+        export_media=None,
         relative_filepaths=True,
         pretty_print=False,
     ):
-        super().__init__(export_dir)
+        if export_media is None:
+            export_media = True
+
+        super().__init__(export_dir=export_dir)
+
         self.export_media = export_media
-        self.move_media = move_media
         self.relative_filepaths = relative_filepaths
         self.pretty_print = pretty_print
+
         self._data_dir = None
         self._eval_dir = None
         self._brain_dir = None
@@ -1044,9 +1354,13 @@ def setup(self):
         self._samples_path = os.path.join(self.export_dir, "samples.json")
         self._metadata = {}
         self._samples = []
-        self._filename_maker = fou.UniqueFilenameMaker(
-            output_dir=self._data_dir
-        )
+
+        if self.export_media != False:
+            output_dir = self._data_dir
+        else:
+            output_dir = None
+
+        self._filename_maker = fou.UniqueFilenameMaker(output_dir=output_dir)
 
     def log_collection(self, sample_collection):
         self._is_video_dataset = sample_collection.media_type == fomm.VIDEO
@@ -1105,18 +1419,16 @@ def log_collection(self, sample_collection):
     def export_sample(self, sample):
         sd = sample.to_dict()
 
-        if self.export_media:
-            out_filepath = self._filename_maker.get_output_path(
-                sample.filepath
-            )
-            if self.move_media:
-                etau.move_file(sample.filepath, out_filepath)
-            else:
-                etau.copy_file(sample.filepath, out_filepath)
-            sd["filepath"] = out_filepath
+        out_filepath = self._filename_maker.get_output_path(sample.filepath)
 
-        else:
-            out_filepath = sample.filepath
+        if self.export_media == True:
+            etau.copy_file(sample.filepath, out_filepath)
+        elif self.export_media == "move":
+            etau.move_file(sample.filepath, out_filepath)
+        elif self.export_media == "symlink":
+            etau.symlink_file(sample.filepath, out_filepath)
+
+        sd["filepath"] = out_filepath
 
         if self.relative_filepaths:
             sd["filepath"] = os.path.relpath(out_filepath, self.export_dir)
@@ -1155,25 +1467,34 @@ class FiftyOneDatasetExporter(BatchDatasetExporter):
 
     Args:
         export_dir: the directory to write the export
-        include_media (True): whether to include the source media in the export
-        move_media (False): whether to move (True) or copy (False) the source
-            media into its output destination
+        export_media (None): defines how to export the raw media contained
+            in the dataset. The supported values are:
+
+            -   ``True`` (default): copy all media files into the export
+                directory
+            -   ``False``: don't export media
+            -   ``"move"``: move media files into the export directory
+            -   ``"symlink"``: create symlinks to each media file in the export
+                directory
+
         rel_dir (None): a relative directory to remove from the ``filepath`` of
             each sample, if possible. The path is converted to an absolute path
             (if necessary) via ``os.path.abspath(os.path.expanduser(rel_dir))``.
             The typical use case for this argument is that your source data
             lives in a single directory and you wish to serialize relative,
             rather than absolute, paths to the data within that directory.
-            Only applicable when ``include_media`` is False
+            Only applicable when ``export_media`` is False
     """
 
-    def __init__(
-        self, export_dir, include_media=True, move_media=False, rel_dir=None
-    ):
-        super().__init__(export_dir)
-        self.include_media = include_media
-        self.move_media = move_media
+    def __init__(self, export_dir, export_media=None, rel_dir=None):
+        if export_media is None:
+            export_media = True
+
+        super().__init__(export_dir=export_dir)
+
+        self.export_media = export_media
         self.rel_dir = rel_dir
+
         self._data_dir = None
         self._eval_dir = None
         self._brain_dir = None
@@ -1189,7 +1510,8 @@ def setup(self):
         self._metadata_path = os.path.join(self.export_dir, "metadata.json")
         self._samples_path = os.path.join(self.export_dir, "samples.json")
         self._frames_path = os.path.join(self.export_dir, "frames.json")
-        if self.include_media:
+
+        if self.export_media != False:
             self._filename_maker = fou.UniqueFilenameMaker(
                 output_dir=self._data_dir
             )
@@ -1199,10 +1521,10 @@ def export_samples(self, sample_collection):
 
         inpaths = sample_collection.values("filepath")
 
-        if self.include_media:
+        if self.export_media != False:
             if self.rel_dir is not None:
                 logger.warning(
-                    "Ignoring `rel_dir` since `include_media` is True"
+                    "Ignoring `rel_dir` since `export_media` is True"
                 )
 
             outpaths = [
@@ -1267,9 +1589,18 @@ def export_samples(self, sample_collection):
         if export_runs and sample_collection.has_brain_runs:
             _export_brain_results(sample_collection, self._brain_dir)
 
-        if self.include_media:
+        if self.export_media == True:
+            mode = "copy"
+        elif self.export_media == "move":
+            mode = "move"
+        elif self.export_media == "symlink":
+            mode = "symlink"
+        else:
+            mode = None
+
+        if mode is not None:
             logger.info("Exporting media...")
-            fomm.export_media(inpaths, outpaths, move_media=self.move_media)
+            fomm.export_media(inpaths, outpaths, mode=mode)
 
 
 def _export_evaluation_results(sample_collection, eval_dir):
@@ -1294,37 +1625,54 @@ class ImageDirectoryExporter(UnlabeledImageDatasetExporter):
     See :class:`fiftyone.types.dataset_types.ImageDirectory` for format
     details.
 
-    If the path to an image is provided, the image is directly copied to its
-    destination, maintaining the original filename, unless a name conflict
-    would occur, in which case an index of the form ``"-%d" % count`` is
-    appended to the base filename.
+    The filenames of input image paths will be maintained in the export
+    directory, unless a name conflict would occur, in which case an index of
+    the form ``"-%d" % count`` is appended to the base filename.
 
     Args:
         export_dir: the directory to write the export
+        export_media (None): defines how to export the raw media contained
+            in the dataset. The supported values are:
+
+            -   ``True`` (default): copy all media files into the export
+                directory
+            -   ``"move"``: move media files into the export directory
+            -   ``"symlink"``: create symlinks to each media file in the export
+                directory
         image_format (None): the image format to use when writing in-memory
             images to disk. By default, ``fiftyone.config.default_image_ext``
             is used
     """
 
-    def __init__(self, export_dir, image_format=None):
-        if image_format is None:
-            image_format = fo.config.default_image_ext
+    def __init__(self, export_dir, export_media=None, image_format=None):
+        if export_media is None:
+            export_media = True
 
-        super().__init__(export_dir)
+        super().__init__(export_dir=export_dir)
+
+        self.export_media = export_media
         self.image_format = image_format
-        self._filename_maker = None
+
+        self._media_exporter = None
 
     @property
     def requires_image_metadata(self):
         return False
 
     def setup(self):
-        self._filename_maker = fou.UniqueFilenameMaker(
-            output_dir=self.export_dir, default_ext=self.image_format
+        self._media_exporter = ImageExporter(
+            self.export_media,
+            export_path=self.export_dir,
+            supported_modes=(True, "move", "symlink"),
+            default_ext=self.image_format,
         )
+        self._media_exporter.setup()
 
     def export_sample(self, image_or_path, metadata=None):
-        self._export_image_or_path(image_or_path, self._filename_maker)
+        self._media_exporter.export(image_or_path)
+
+    def close(self, *args):
+        self._media_exporter.close()
 
 
 class VideoDirectoryExporter(UnlabeledVideoDatasetExporter):
@@ -1333,33 +1681,54 @@ class VideoDirectoryExporter(UnlabeledVideoDatasetExporter):
     See :class:`fiftyone.types.dataset_types.VideoDirectory` for format
     details.
 
-    If the path to a video is provided, the video is directly copied to its
-    destination, maintaining the original filename, unless a name conflict
-    would occur, in which case an index of the form ``"-%d" % count`` is
-    appended to the base filename.
+    The filenames of the input videos will be maintained in the export
+    directory, unless a name conflict would occur, in which case an index of
+    the form ``"-%d" % count`` is appended to the base filename.
 
     Args:
         export_dir: the directory to write the export
+        export_media (None): defines how to export the raw media contained
+            in the dataset. The supported values are:
+
+            -   ``True`` (default): copy all media files into the export
+                directory
+            -   ``"move"``: move media files into the export directory
+            -   ``"symlink"``: create symlinks to each media file in the export
+                directory
     """
 
-    def __init__(self, export_dir):
-        super().__init__(export_dir)
-        self._filename_maker = None
+    def __init__(self, export_dir, export_media=None):
+        if export_media is None:
+            export_media = True
+
+        super().__init__(export_dir=export_dir)
+
+        self.export_media = export_media
+
+        self._media_exporter = None
 
     @property
     def requires_video_metadata(self):
         return False
 
     def setup(self):
-        self._filename_maker = fou.UniqueFilenameMaker(
-            output_dir=self.export_dir
+        self._media_exporter = ImageExporter(
+            self.export_media,
+            export_path=self.export_dir,
+            supported_modes=(True, "move", "symlink"),
         )
+        self._media_exporter.setup()
 
     def export_sample(self, video_path, metadata=None):
-        self._export_video(video_path, self._filename_maker)
+        self._media_exporter.export(video_path)
+
+    def close(self, *args):
+        self._media_exporter.close()
 
 
-class FiftyOneImageClassificationDatasetExporter(LabeledImageDatasetExporter):
+class FiftyOneImageClassificationDatasetExporter(
+    LabeledImageDatasetExporter, ExportPathsMixin
+):
     """Exporter that writes an image classification dataset to disk in
     FiftyOne's default format.
 
@@ -1372,7 +1741,52 @@ class FiftyOneImageClassificationDatasetExporter(LabeledImageDatasetExporter):
     appended to the base filename.
 
     Args:
-        export_dir: the directory to write the export
+        export_dir (None): the directory to write the export. This has no
+            effect if ``data_path`` and ``labels_path`` are absolute paths
+        data_path (None): an optional parameter that enables explicit control
+            over the location of the exported media. Can be any of the
+            following:
+
+            -   a folder name like ``"data"`` or ``"data/"`` specifying a
+                subfolder of ``export_dir`` in which to export the media
+            -   an absolute directory path in which to export the media. In
+                this case, the ``export_dir`` has no effect on the location of
+                the data
+            -   a JSON filename like ``"data.json"`` specifying the filename of
+                the manifest file in ``export_dir`` generated when
+                ``export_media`` is ``"manifest"``
+            -   an absolute filepath specifying the location to write the JSON
+                manifest file when ``export_media`` is ``"manifest"``. In this
+                case, ``export_dir`` has no effect on the location of the data
+
+            If None, the default value of this parameter will be chosen based
+            on the value of the ``export_media`` parameter
+        labels_path (None): an optional parameter that enables explicit control
+            over the location of the exported labels. Can be any of the
+            following:
+
+            -   a filename like ``"labels.json"`` specifying the location in
+                ``export_dir`` in which to export the labels
+            -   an absolute filepath to which to export the labels. In this
+                case, the ``export_dir`` has no effect on the location of the
+                labels
+
+            If None, the labels will be exported into ``export_dir`` using the
+            default filename
+        export_media (None): controls how to export the raw media. The
+            supported values are:
+
+            -   ``True``: copy all media files into the output directory
+            -   ``False``: don't export media
+            -   ``"move"``: move all media files into the output directory
+            -   ``"symlink"``: create symlinks to the media files in the output
+                directory
+            -   ``"manifest"``: create a ``data.json`` in the output directory
+                that maps UUIDs used in the labels files to the filepaths of
+                the source media, rather than exporting the actual media
+
+            If None, the default value of this parameter will be chosen based
+            on the value of the ``data_path`` parameter
         classes (None): the list of possible class labels. If not provided,
             this list will be extracted when :meth:`log_collection` is called,
             if possible
@@ -1384,20 +1798,40 @@ class FiftyOneImageClassificationDatasetExporter(LabeledImageDatasetExporter):
     """
 
     def __init__(
-        self, export_dir, classes=None, image_format=None, pretty_print=False
+        self,
+        export_dir=None,
+        data_path=None,
+        labels_path=None,
+        export_media=None,
+        classes=None,
+        image_format=None,
+        pretty_print=False,
     ):
-        if image_format is None:
-            image_format = fo.config.default_image_ext
+        data_path, export_media = self._parse_data_path(
+            export_dir=export_dir,
+            data_path=data_path,
+            export_media=export_media,
+            default="data/",
+        )
 
-        super().__init__(export_dir)
+        labels_path = self._parse_labels_path(
+            export_dir=export_dir,
+            labels_path=labels_path,
+            default="labels.json",
+        )
+
+        super().__init__(export_dir=export_dir)
+
+        self.data_path = data_path
+        self.labels_path = labels_path
+        self.export_media = export_media
         self.classes = classes
         self.image_format = image_format
         self.pretty_print = pretty_print
-        self._data_dir = None
-        self._labels_path = None
+
         self._labels_dict = None
         self._labels_map_rev = None
-        self._filename_maker = None
+        self._media_exporter = None
 
     @property
     def requires_image_metadata(self):
@@ -1408,15 +1842,16 @@ def label_cls(self):
         return fol.Classification
 
     def setup(self):
-        self._data_dir = os.path.join(self.export_dir, "data")
-        self._labels_path = os.path.join(self.export_dir, "labels.json")
         self._labels_dict = {}
-        self._filename_maker = fou.UniqueFilenameMaker(
-            output_dir=self._data_dir,
+        self._parse_classes()
+
+        self._media_exporter = ImageExporter(
+            self.export_media,
+            export_path=self.data_path,
             default_ext=self.image_format,
             ignore_exts=True,
         )
-        self._parse_classes()
+        self._media_exporter.setup()
 
     def log_collection(self, sample_collection):
         if self.classes is None:
@@ -1431,11 +1866,8 @@ def log_collection(self, sample_collection):
                 self._parse_classes()
 
     def export_sample(self, image_or_path, classification, metadata=None):
-        out_image_path = self._export_image_or_path(
-            image_or_path, self._filename_maker
-        )
-        name = os.path.splitext(os.path.basename(out_image_path))[0]
-        self._labels_dict[name] = _parse_classification(
+        _, uuid = self._media_exporter.export(image_or_path)
+        self._labels_dict[uuid] = _parse_classification(
             classification, labels_map_rev=self._labels_map_rev
         )
 
@@ -1445,8 +1877,9 @@ def close(self, *args):
             "labels": self._labels_dict,
         }
         etas.write_json(
-            labels, self._labels_path, pretty_print=self.pretty_print
+            labels, self.labels_path, pretty_print=self.pretty_print
         )
+        self._media_exporter.close()
 
     def _parse_classes(self):
         if self.classes is not None:
@@ -1459,24 +1892,44 @@ class ImageClassificationDirectoryTreeExporter(LabeledImageDatasetExporter):
     See :class:`fiftyone.types.dataset_types.ImageClassificationDirectoryTree`
     for format details.
 
-    If the path to an image is provided, the image is directly copied to its
-    destination, maintaining the original filename, unless a name conflict
+    The filenames of the input images are maintained, unless a name conflict
     would occur, in which case an index of the form ``"-%d" % count`` is
     appended to the base filename.
 
     Args:
         export_dir: the directory to write the export
+        export_media (None): controls how to export the raw media. The
+            supported values are:
+
+            -   ``True`` (default): copy all media files into the output
+                directory
+            -   ``"move"``: move all media files into the output directory
+            -   ``"symlink"``: create symlinks to the media files in the output
+                directory
         image_format (None): the image format to use when writing in-memory
             images to disk. By default, ``fiftyone.config.default_image_ext``
             is used
     """
 
-    def __init__(self, export_dir, image_format=None):
+    def __init__(self, export_dir, export_media=None, image_format=None):
+        if export_media is None:
+            export_media = True
+
+        supported_modes = (True, "move", "symlink")
+        if export_media not in supported_modes:
+            raise ValueError(
+                "Unsupported export_media=%s for %s. The supported values "
+                "are %s" % (export_media, type(self), supported_modes)
+            )
+
         if image_format is None:
             image_format = fo.config.default_image_ext
 
-        super().__init__(export_dir)
+        super().__init__(export_dir=export_dir)
+
+        self.export_media = export_media
         self.image_format = image_format
+
         self._class_counts = None
         self._filename_counts = None
         self._default_filename_patt = (
@@ -1497,7 +1950,7 @@ def setup(self):
         etau.ensure_dir(self.export_dir)
 
     def export_sample(self, image_or_path, classification, metadata=None):
-        is_image_path = self._is_image_path(image_or_path)
+        is_image_path = etau.is_str(image_or_path)
 
         _label = _parse_classification(classification)
         if _label is None:
@@ -1525,7 +1978,12 @@ def export_sample(self, image_or_path, classification, metadata=None):
         out_image_path = os.path.join(self.export_dir, _label, filename)
 
         if is_image_path:
-            etau.copy_file(image_path, out_image_path)
+            if self.export_media == "move":
+                etau.move_file(image_path, out_image_path)
+            elif self.export_media == "symlink":
+                etau.symlink_file(image_path, out_image_path)
+            else:
+                etau.copy_file(image_path, out_image_path)
         else:
             etai.write(img, out_image_path)
 
@@ -1536,17 +1994,38 @@ class VideoClassificationDirectoryTreeExporter(LabeledVideoDatasetExporter):
     See :class:`fiftyone.types.dataset_types.VideoClassificationDirectoryTree`
     for format details.
 
-    The source videos are directly copied to their export destination,
-    maintaining the original filename, unless a name conflict would occur, in
-    which case an index of the form ``"-%d" % count`` is appended to the base
-    filename.
+    The filenames of the input images are maintained, unless a name conflict
+    would occur, in which case an index of the form ``"-%d" % count`` is
+    appended to the base filename.
 
     Args:
         export_dir: the directory to write the export
+        export_media (None): controls how to export the raw media. The
+            supported values are:
+
+            -   ``True`` (default): copy all media files into the output
+                directory
+            -   ``False``: don't export media
+            -   ``"move"``: move all media files into the output directory
+            -   ``"symlink"``: create symlinks to the media files in the output
+                directory
     """
 
-    def __init__(self, export_dir):
-        super().__init__(export_dir)
+    def __init__(self, export_dir, export_media=None):
+        if export_media is None:
+            export_media = True
+
+        supported_modes = (True, "move", "symlink")
+        if export_media not in supported_modes:
+            raise ValueError(
+                "Unsupported export_media=%s for %s. The supported values are "
+                "%s" % (export_media, type(self), supported_modes)
+            )
+
+        super().__init__(export_dir=export_dir)
+
+        self.export_media = export_media
+
         self._class_counts = None
         self._filename_counts = None
 
@@ -1585,10 +2064,17 @@ def export_sample(self, video_path, classification, _, metadata=None):
 
         out_video_path = os.path.join(self.export_dir, _label, filename)
 
-        etau.copy_file(video_path, out_video_path)
+        if self.export_media == "move":
+            etau.move_file(video_path, out_video_path)
+        elif self.export_media == "symlink":
+            etau.symlink_file(video_path, out_video_path)
+        else:
+            etau.copy_file(video_path, out_video_path)
 
 
-class FiftyOneImageDetectionDatasetExporter(LabeledImageDatasetExporter):
+class FiftyOneImageDetectionDatasetExporter(
+    LabeledImageDatasetExporter, ExportPathsMixin
+):
     """Exporter that writes an image detection dataset to disk in FiftyOne's
     default format.
 
@@ -1601,7 +2087,52 @@ class FiftyOneImageDetectionDatasetExporter(LabeledImageDatasetExporter):
     appended to the base filename.
 
     Args:
-        export_dir: the directory to write the export
+        export_dir (None): the directory to write the export. This has no
+            effect if ``data_path`` and ``labels_path`` are absolute paths
+        data_path (None): an optional parameter that enables explicit control
+            over the location of the exported media. Can be any of the
+            following:
+
+            -   a folder name like ``"data"`` or ``"data/"`` specifying a
+                subfolder of ``export_dir`` in which to export the media
+            -   an absolute directory path in which to export the media. In
+                this case, the ``export_dir`` has no effect on the location of
+                the data
+            -   a JSON filename like ``"data.json"`` specifying the filename of
+                the manifest file in ``export_dir`` generated when
+                ``export_media`` is ``"manifest"``
+            -   an absolute filepath specifying the location to write the JSON
+                manifest file when ``export_media`` is ``"manifest"``. In this
+                case, ``export_dir`` has no effect on the location of the data
+
+            If None, the default value of this parameter will be chosen based
+            on the value of the ``export_media`` parameter
+        labels_path (None): an optional parameter that enables explicit control
+            over the location of the exported labels. Can be any of the
+            following:
+
+            -   a filename like ``"labels.json"`` specifying the location in
+                ``export_dir`` in which to export the labels
+            -   an absolute filepath to which to export the labels. In this
+                case, the ``export_dir`` has no effect on the location of the
+                labels
+
+            If None, the labels will be exported into ``export_dir`` using the
+            default filename
+        export_media (None): controls how to export the raw media. The
+            supported values are:
+
+            -   ``True``: copy all media files into the output directory
+            -   ``False``: don't export media
+            -   ``"move"``: move all media files into the output directory
+            -   ``"symlink"``: create symlinks to the media files in the output
+                directory
+            -   ``"manifest"``: create a ``data.json`` in the output directory
+                that maps UUIDs used in the labels files to the filepaths of
+                the source media, rather than exporting the actual media
+
+            If None, the default value of this parameter will be chosen based
+            on the value of the ``data_path`` parameter
         classes (None): the list of possible class labels. If not provided,
             this list will be extracted when :meth:`log_collection` is called,
             if possible
@@ -1613,20 +2144,40 @@ class FiftyOneImageDetectionDatasetExporter(LabeledImageDatasetExporter):
     """
 
     def __init__(
-        self, export_dir, classes=None, image_format=None, pretty_print=False
+        self,
+        export_dir=None,
+        data_path=None,
+        labels_path=None,
+        export_media=None,
+        classes=None,
+        image_format=None,
+        pretty_print=False,
     ):
-        if image_format is None:
-            image_format = fo.config.default_image_ext
+        data_path, export_media = self._parse_data_path(
+            export_dir=export_dir,
+            data_path=data_path,
+            export_media=export_media,
+            default="data/",
+        )
+
+        labels_path = self._parse_labels_path(
+            export_dir=export_dir,
+            labels_path=labels_path,
+            default="labels.json",
+        )
 
-        super().__init__(export_dir)
+        super().__init__(export_dir=export_dir)
+
+        self.data_path = data_path
+        self.labels_path = labels_path
+        self.export_media = export_media
         self.classes = classes
         self.image_format = image_format
         self.pretty_print = pretty_print
-        self._data_dir = None
-        self._labels_path = None
+
         self._labels_dict = None
         self._labels_map_rev = None
-        self._filename_maker = None
+        self._media_exporter = None
 
     @property
     def requires_image_metadata(self):
@@ -1637,15 +2188,16 @@ def label_cls(self):
         return fol.Detections
 
     def setup(self):
-        self._data_dir = os.path.join(self.export_dir, "data")
-        self._labels_path = os.path.join(self.export_dir, "labels.json")
         self._labels_dict = {}
-        self._filename_maker = fou.UniqueFilenameMaker(
-            output_dir=self._data_dir,
+        self._parse_classes()
+
+        self._media_exporter = ImageExporter(
+            self.export_media,
+            export_path=self.data_path,
             default_ext=self.image_format,
             ignore_exts=True,
         )
-        self._parse_classes()
+        self._media_exporter.setup()
 
     def log_collection(self, sample_collection):
         if self.classes is None:
@@ -1660,11 +2212,8 @@ def log_collection(self, sample_collection):
                 self._parse_classes()
 
     def export_sample(self, image_or_path, detections, metadata=None):
-        out_image_path = self._export_image_or_path(
-            image_or_path, self._filename_maker
-        )
-        name = os.path.splitext(os.path.basename(out_image_path))[0]
-        self._labels_dict[name] = _parse_detections(
+        _, uuid = self._media_exporter.export(image_or_path)
+        self._labels_dict[uuid] = _parse_detections(
             detections, labels_map_rev=self._labels_map_rev
         )
 
@@ -1674,15 +2223,18 @@ def close(self, *args):
             "labels": self._labels_dict,
         }
         etas.write_json(
-            labels, self._labels_path, pretty_print=self.pretty_print
+            labels, self.labels_path, pretty_print=self.pretty_print
         )
+        self._media_exporter.close()
 
     def _parse_classes(self):
         if self.classes is not None:
             self._labels_map_rev = _to_labels_map_rev(self.classes)
 
 
-class ImageSegmentationDirectoryExporter(LabeledImageDatasetExporter):
+class ImageSegmentationDirectoryExporter(
+    LabeledImageDatasetExporter, ExportPathsMixin
+):
     """Exporter that writes an image segmentation dataset to disk.
 
     See :class:`fiftyone.types.dataset_types.ImageSegmentationDirectory` for
@@ -1694,7 +2246,52 @@ class ImageSegmentationDirectoryExporter(LabeledImageDatasetExporter):
     appended to the base filename.
 
     Args:
-        export_dir: the directory to write the export
+        export_dir (None): the directory to write the export. This has no
+            effect if ``data_path`` and ``labels_path`` are absolute paths
+        data_path (None): an optional parameter that enables explicit control
+            over the location of the exported media. Can be any of the
+            following:
+
+            -   a folder name like ``"data"`` or ``"data/"`` specifying a
+                subfolder of ``export_dir`` in which to export the media
+            -   an absolute directory path in which to export the media. In
+                this case, the ``export_dir`` has no effect on the location of
+                the data
+            -   a JSON filename like ``"data.json"`` specifying the filename of
+                the manifest file in ``export_dir`` generated when
+                ``export_media`` is ``"manifest"``
+            -   an absolute filepath specifying the location to write the JSON
+                manifest file when ``export_media`` is ``"manifest"``. In this
+                case, ``export_dir`` has no effect on the location of the data
+
+            If None, the default value of this parameter will be chosen based
+            on the value of the ``export_media`` parameter
+        labels_path (None): an optional parameter that enables explicit control
+            over the location of the exported labels. Can be any of the
+            following:
+
+            -   a folder name like ``"labels"`` or ``"labels/"`` specifying the
+                location in ``export_dir`` in which to export the masks
+            -   an absolute directory in which to export the masks. In this
+                case, the ``export_dir`` has no effect on the location of the
+                masks
+
+            If None, the masks will be exported into ``export_dir`` using the
+            default folder name
+        export_media (None): controls how to export the raw media. The
+            supported values are:
+
+            -   ``True``: copy all media files into the output directory
+            -   ``False``: don't export media
+            -   ``"move"``: move all media files into the output directory
+            -   ``"symlink"``: create symlinks to the media files in the output
+                directory
+            -   ``"manifest"``: create a ``data.json`` in the output directory
+                that maps UUIDs used in the labels files to the filepaths of
+                the source media, rather than exporting the actual media
+
+            If None, the default value of this parameter will be chosen based
+            on the value of the ``data_path`` parameter
         image_format (None): the image format to use when writing in-memory
             images to disk. By default, ``fiftyone.config.default_image_ext``
             is used
@@ -1702,16 +2299,35 @@ class ImageSegmentationDirectoryExporter(LabeledImageDatasetExporter):
             disk
     """
 
-    def __init__(self, export_dir, image_format=None, mask_format=".png"):
-        if image_format is None:
-            image_format = fo.config.default_image_ext
+    def __init__(
+        self,
+        export_dir=None,
+        data_path=None,
+        labels_path=None,
+        export_media=None,
+        image_format=None,
+        mask_format=".png",
+    ):
+        data_path, export_media = self._parse_data_path(
+            export_dir=export_dir,
+            data_path=data_path,
+            export_media=export_media,
+            default="data/",
+        )
+
+        labels_path = self._parse_labels_path(
+            export_dir=export_dir, labels_path=labels_path, default="labels/",
+        )
+
+        super().__init__(export_dir=export_dir)
 
-        super().__init__(export_dir)
+        self.data_path = data_path
+        self.labels_path = labels_path
+        self.export_media = export_media
         self.image_format = image_format
         self.mask_format = mask_format
-        self._data_dir = None
-        self._labels_dir = None
-        self._filename_maker = None
+
+        self._media_exporter = None
 
     @property
     def requires_image_metadata(self):
@@ -1722,23 +2338,22 @@ def label_cls(self):
         return fol.Segmentation
 
     def setup(self):
-        self._data_dir = os.path.join(self.export_dir, "data")
-        self._labels_dir = os.path.join(self.export_dir, "labels")
-        self._filename_maker = fou.UniqueFilenameMaker(
-            output_dir=self._data_dir,
+        self._media_exporter = ImageExporter(
+            self.export_media,
+            export_path=self.data_path,
             default_ext=self.image_format,
             ignore_exts=True,
         )
+        self._media_exporter.setup()
 
     def export_sample(self, image_or_path, segmentation, metadata=None):
-        out_image_path = self._export_image_or_path(
-            image_or_path, self._filename_maker
-        )
-        name = os.path.splitext(os.path.basename(out_image_path))[0]
-
-        out_mask_path = os.path.join(self._labels_dir, name + self.mask_format)
+        _, uuid = self._media_exporter.export(image_or_path)
+        out_mask_path = os.path.join(self.labels_path, uuid + self.mask_format)
         etai.write(segmentation.mask, out_mask_path)
 
+    def close(self, *args):
+        self._media_exporter.close()
+
 
 class FiftyOneImageLabelsDatasetExporter(LabeledImageDatasetExporter):
     """Exporter that writes a labeled image dataset to disk with labels stored
@@ -1754,6 +2369,15 @@ class FiftyOneImageLabelsDatasetExporter(LabeledImageDatasetExporter):
 
     Args:
         export_dir: the directory to write the export
+        export_media (None): controls how to export the raw media. The
+            supported values are:
+
+            -   ``True`` (default): copy all media files into the output
+                directory
+            -   ``False``: don't export media
+            -   ``"move"``: move all media files into the output directory
+            -   ``"symlink"``: create symlinks to the media files in the output
+                directory
         image_format (None): the image format to use when writing in-memory
             images to disk. By default, ``fiftyone.config.default_image_ext``
             is used
@@ -1761,18 +2385,27 @@ class FiftyOneImageLabelsDatasetExporter(LabeledImageDatasetExporter):
             format with newlines and indentations
     """
 
-    def __init__(self, export_dir, image_format=None, pretty_print=False):
-        if image_format is None:
-            image_format = fo.config.default_image_ext
+    def __init__(
+        self,
+        export_dir,
+        export_media=None,
+        image_format=None,
+        pretty_print=False,
+    ):
+        if export_media is None:
+            export_media = True
+
+        super().__init__(export_dir=export_dir)
 
-        super().__init__(export_dir)
+        self.export_media = export_media
         self.image_format = image_format
         self.pretty_print = pretty_print
+
         self._labeled_dataset = None
         self._data_dir = None
         self._labels_dir = None
-        self._filename_maker = None
         self._description = None
+        self._media_exporter = None
 
     @property
     def requires_image_metadata(self):
@@ -1793,53 +2426,48 @@ def setup(self):
         )
         self._data_dir = self._labeled_dataset.data_dir
         self._labels_dir = self._labeled_dataset.labels_dir
-        self._filename_maker = fou.UniqueFilenameMaker(
-            output_dir=self._data_dir,
+
+        self._media_exporter = ImageExporter(
+            self.export_media,
+            export_path=self._data_dir,
+            supported_modes=(True, False, "move", "symlink"),
             default_ext=self.image_format,
             ignore_exts=True,
         )
+        self._media_exporter.setup()
 
     def log_collection(self, sample_collection):
         self._description = sample_collection.info.get("description", None)
 
     def export_sample(self, image_or_path, labels, metadata=None):
-        is_image_path = self._is_image_path(image_or_path)
-
-        if is_image_path:
-            image_path = image_or_path
-            out_image_path = self._filename_maker.get_output_path(image_path)
-        else:
-            img = image_or_path
-            out_image_path = self._filename_maker.get_output_path()
+        out_image_path, uuid = self._media_exporter.export(image_or_path)
 
-        name, ext = os.path.splitext(os.path.basename(out_image_path))
-        new_image_filename = name + ext
-        new_labels_filename = name + ".json"
+        out_image_filename = os.path.basename(out_image_path)
+        out_labels_filename = uuid + ".json"
 
         _image_labels = foe.to_image_labels(labels)
 
         if etau.is_str(image_or_path):
             image_labels_path = os.path.join(
-                self._labels_dir, new_labels_filename
+                self._labels_dir, out_labels_filename
             )
             _image_labels.write_json(
                 image_labels_path, pretty_print=self.pretty_print
             )
 
-            self._labeled_dataset.add_file(
-                image_path,
-                image_labels_path,
-                new_data_filename=new_image_filename,
-                new_labels_filename=new_labels_filename,
-            )
+            self._labeled_dataset.add_file(out_image_path, image_labels_path)
         else:
             self._labeled_dataset.add_data(
-                img, _image_labels, new_image_filename, new_labels_filename,
+                image_or_path,
+                _image_labels,
+                out_image_filename,
+                out_labels_filename,
             )
 
     def close(self, *args):
         self._labeled_dataset.set_description(self._description)
         self._labeled_dataset.write_manifest()
+        self._media_exporter.close()
 
 
 class FiftyOneVideoLabelsDatasetExporter(LabeledVideoDatasetExporter):
@@ -1856,18 +2484,33 @@ class FiftyOneVideoLabelsDatasetExporter(LabeledVideoDatasetExporter):
 
     Args:
         export_dir: the directory to write the export
+        export_media (None): controls how to export the raw media. The
+            supported values are:
+
+            -   ``True`` (default): copy all media files into the output
+                directory
+            -   ``False``: don't export media
+            -   ``"move"``: move all media files into the output directory
+            -   ``"symlink"``: create symlinks to the media files in the output
+                directory
         pretty_print (False): whether to render the JSON in human readable
             format with newlines and indentations
     """
 
-    def __init__(self, export_dir, pretty_print=False):
-        super().__init__(export_dir)
+    def __init__(self, export_dir, export_media=None, pretty_print=False):
+        if export_media is None:
+            export_media = True
+
+        super().__init__(export_dir=export_dir)
+
+        self.export_media = export_media
         self.pretty_print = pretty_print
+
         self._labeled_dataset = None
         self._data_dir = None
         self._labels_dir = None
-        self._filename_maker = None
         self._description = None
+        self._media_exporter = None
 
     @property
     def requires_video_metadata(self):
@@ -1892,37 +2535,36 @@ def setup(self):
         )
         self._data_dir = self._labeled_dataset.data_dir
         self._labels_dir = self._labeled_dataset.labels_dir
-        self._filename_maker = fou.UniqueFilenameMaker(
-            output_dir=self._data_dir, ignore_exts=True,
+
+        self._media_exporter = VideoExporter(
+            self.export_media,
+            export_path=self._data_dir,
+            supported_modes=(True, False, "move", "symlink"),
+            ignore_exts=True,
         )
+        self._media_exporter.setup()
 
     def log_collection(self, sample_collection):
         self._description = sample_collection.info.get("description", None)
 
     def export_sample(self, video_path, _, frames, metadata=None):
-        out_video_path = self._filename_maker.get_output_path(video_path)
+        out_video_path, uuid = self._media_exporter.export(video_path)
 
-        name, ext = os.path.splitext(os.path.basename(out_video_path))
-        new_image_filename = name + ext
-        new_labels_filename = name + ".json"
+        out_labels_filename = uuid + ".json"
 
         _video_labels = foe.to_video_labels(frames)
 
-        video_labels_path = os.path.join(self._labels_dir, new_labels_filename)
+        video_labels_path = os.path.join(self._labels_dir, out_labels_filename)
         _video_labels.write_json(
             video_labels_path, pretty_print=self.pretty_print
         )
 
-        self._labeled_dataset.add_file(
-            video_path,
-            video_labels_path,
-            new_data_filename=new_image_filename,
-            new_labels_filename=new_labels_filename,
-        )
+        self._labeled_dataset.add_file(out_video_path, video_labels_path)
 
     def close(self, *args):
         self._labeled_dataset.set_description(self._description)
         self._labeled_dataset.write_manifest()
+        self._media_exporter.close()
 
 
 def _parse_classification(classification, labels_map_rev=None):
diff --git a/fiftyone/utils/data/importers.py b/fiftyone/utils/data/importers.py
index e7249fed2fd..a52b058b146 100644
--- a/fiftyone/utils/data/importers.py
+++ b/fiftyone/utils/data/importers.py
@@ -6,6 +6,7 @@
 |
 """
 from copy import copy
+import inspect
 import logging
 import os
 import random
@@ -26,10 +27,11 @@
 import fiftyone.core.labels as fol
 import fiftyone.core.metadata as fom
 import fiftyone.core.media as fomm
-import fiftyone.migrations as fomi
 import fiftyone.core.odm as foo
 import fiftyone.core.runs as fors
 import fiftyone.core.sample as fos
+import fiftyone.core.utils as fou
+import fiftyone.migrations as fomi
 
 from .parsers import (
     FiftyOneImageClassificationSampleParser,
@@ -59,8 +61,8 @@ def import_samples(
     Args:
         dataset: a :class:`fiftyone.core.dataset.Dataset`
         dataset_importer: a :class:`DatasetImporter`
-        label_field (None): the name of the field in which to store the
-            imported labels. Only applicable if ``dataset_importer`` is a
+        label_field (None): the name (or root name) of the field(s) to use for
+            the labels. Only applicable if ``dataset_importer`` is a
             :class:`LabeledImageDatasetImporter`
         tags (None): an optional tag or iterable of tags to attach to each
                 sample
@@ -78,7 +80,228 @@ def import_samples(
     elif tags is not None:
         tags = list(tags)
 
-    # Handle data in legacy format
+    dataset_importer = _handle_legacy_formats(dataset_importer)
+
+    # Batch imports
+    if isinstance(dataset_importer, BatchDatasetImporter):
+        # @todo support `expand_schema=False` here?
+        if not expand_schema:
+            logger.warning(
+                "`expand_schema=False` is not supported for %s instances",
+                BatchDatasetImporter,
+            )
+
+        if not add_info:
+            logger.warning(
+                "`add_info=False` is not supported for %s instances",
+                BatchDatasetImporter,
+            )
+
+        with dataset_importer:
+            return dataset_importer.import_samples(dataset, tags=tags)
+
+    #
+    # Non-batch imports
+    #
+
+    with dataset_importer:
+        parse_sample, expand_schema = _build_parse_sample_fcn(
+            dataset, dataset_importer, label_field, tags, expand_schema
+        )
+
+        try:
+            num_samples = len(dataset_importer)
+        except:
+            num_samples = None
+
+        samples = map(parse_sample, iter(dataset_importer))
+        sample_ids = dataset.add_samples(
+            samples, expand_schema=expand_schema, num_samples=num_samples
+        )
+
+        if add_info and dataset_importer.has_dataset_info:
+            info = dataset_importer.get_dataset_info()
+            if info:
+                parse_dataset_info(dataset, info)
+
+        if isinstance(dataset_importer, LegacyFiftyOneDatasetImporter):
+            dataset_importer.import_run_results(dataset)
+
+    return sample_ids
+
+
+def merge_samples(
+    dataset,
+    dataset_importer,
+    label_field=None,
+    tags=None,
+    key_field="filepath",
+    key_fcn=None,
+    skip_existing=False,
+    insert_new=True,
+    fields=None,
+    omit_fields=None,
+    merge_lists=True,
+    overwrite=True,
+    expand_schema=True,
+    add_info=True,
+):
+    """Merges the samples from the given :class:`DatasetImporter` into the
+    dataset.
+
+    See :ref:`this guide <custom-dataset-importer>` for more details about
+    importing datasets in custom formats by defining your own
+    :class:`DatasetImporter`.
+
+    By default, samples with the same absolute ``filepath`` are merged, but you
+    can customize this behavior via the ``key_field`` and ``key_fcn``
+    parameters. For example, you could set
+    ``key_fcn = lambda sample: os.path.basename(sample.filepath)`` to merge
+    samples with the same base filename.
+
+    The behavior of this method is highly customizable. By default, all
+    top-level fields from the imported samples are merged in, overwriting any
+    existing values for those fields, with the exception of list fields
+    (e.g., ``tags``) and label list fields (e.g.,
+    :class:`fiftyone.core.labels.Detections` fields), in which case the
+    elements of the lists themselves are merged. In the case of label list
+    fields, labels with the same ``id`` in both collections are updated rather
+    than duplicated.
+
+    To avoid confusion between missing fields and fields whose value is
+    ``None``, ``None``-valued fields are always treated as missing while
+    merging.
+
+    This method can be configured in numerous ways, including:
+
+    -   Whether existing samples should be modified or skipped
+    -   Whether new samples should be added or omitted
+    -   Whether new fields can be added to the dataset schema
+    -   Whether list fields should be treated as ordinary fields and merged as
+        a whole rather than merging their elements
+    -   Whether to merge (a) only specific fields or (b) all but certain fields
+    -   Mapping input fields to different field names of this dataset
+
+    Args:
+        dataset: a :class:`fiftyone.core.dataset.Dataset`
+        dataset_importer: a :class:`DatasetImporter`
+        label_field (None): the name (or root name) of the field(s) to use for
+            the labels. Only applicable if ``dataset_importer`` is a
+            :class:`LabeledImageDatasetImporter`
+        tags (None): an optional tag or iterable of tags to attach to each
+            sample
+        key_field ("filepath"): the sample field to use to decide whether to
+            join with an existing sample
+        key_fcn (None): a function that accepts a
+            :class:`fiftyone.core.sample.Sample` instance and computes a key to
+            decide if two samples should be merged. If a ``key_fcn`` is
+            provided, ``key_field`` is ignored
+        skip_existing (False): whether to skip existing samples (True) or merge
+            them (False)
+        insert_new (True): whether to insert new samples (True) or skip them
+            (False)
+        fields (None): an optional field or iterable of fields to which to
+            restrict the merge. If provided, fields other than these are
+            omitted from ``samples`` when merging or adding samples. One
+            exception is that ``filepath`` is always included when adding new
+            samples, since the field is required. This can also be a dict
+            mapping field names of the input collection to field names of this
+            dataset
+        omit_fields (None): an optional field or iterable of fields to exclude
+            from the merge. If provided, these fields are omitted from imported
+            samples, if present. One exception is that ``filepath`` is always
+            included when adding new samples, since the field is required
+        merge_lists (True): whether to merge the elements of list fields
+            (e.g., ``tags``) and label list fields (e.g.,
+            :class:`fiftyone.core.labels.Detections` fields) rather than
+            merging the entire top-level field like other field types. For
+            label lists fields, existing :class:`fiftyone.core.label.Label`
+            elements are either replaced (when ``overwrite`` is True) or kept
+            (when ``overwrite`` is False) when their ``id`` matches a label
+            from the provided samples
+        overwrite (True): whether to overwrite (True) or skip (False) existing
+            fields and label elements
+        expand_schema (True): whether to dynamically add new fields encountered
+            to the dataset schema. If False, an error is raised if a sample's
+            schema is not a subset of the dataset schema
+        add_info (True): whether to add dataset info from the importer (if any)
+            to the dataset
+    """
+    if etau.is_str(tags):
+        tags = [tags]
+    elif tags is not None:
+        tags = list(tags)
+
+    dataset_importer = _handle_legacy_formats(dataset_importer)
+
+    #
+    # Batch imports
+    #
+
+    if isinstance(dataset_importer, BatchDatasetImporter):
+        tmp = fod.Dataset()
+        with dataset_importer:
+            dataset_importer.import_samples(tmp, tags=tags)
+
+        dataset.merge_samples(
+            tmp,
+            key_field=key_field,
+            key_fcn=key_fcn,
+            skip_existing=skip_existing,
+            insert_new=insert_new,
+            fields=fields,
+            omit_fields=omit_fields,
+            merge_lists=merge_lists,
+            overwrite=overwrite,
+            expand_schema=expand_schema,
+            include_info=add_info,
+            overwrite_info=True,
+        )
+
+        tmp.delete()
+
+        return
+
+    #
+    # Non-batch imports
+    #
+
+    with dataset_importer:
+        parse_sample, expand_schema = _build_parse_sample_fcn(
+            dataset, dataset_importer, label_field, tags, expand_schema
+        )
+
+        try:
+            num_samples = len(dataset_importer)
+        except:
+            num_samples = None
+
+        samples = map(parse_sample, iter(dataset_importer))
+
+        dataset.merge_samples(
+            samples,
+            key_field=key_field,
+            key_fcn=key_fcn,
+            skip_existing=skip_existing,
+            insert_new=insert_new,
+            fields=fields,
+            omit_fields=omit_fields,
+            merge_lists=merge_lists,
+            overwrite=overwrite,
+            expand_schema=expand_schema,
+            num_samples=num_samples,
+        )
+
+        if add_info and dataset_importer.has_dataset_info:
+            info = dataset_importer.get_dataset_info()
+            if info:
+                parse_dataset_info(dataset, info)
+
+        if isinstance(dataset_importer, LegacyFiftyOneDatasetImporter):
+            dataset_importer.import_run_results(dataset)
+
+
+def _handle_legacy_formats(dataset_importer):
     if (
         isinstance(dataset_importer, FiftyOneDatasetImporter)
         and dataset_importer._is_legacy_format_data()
@@ -87,230 +310,359 @@ def import_samples(
             "Found data in LegacyFiftyOneDataset format; converting to legacy "
             "importer now"
         )
-        dataset_importer = dataset_importer._to_legacy_importer()
+        return dataset_importer._to_legacy_importer()
 
-    # Invoke the importer's context manager first, since some of its properies
-    # may need to be initialized
-    with dataset_importer:
-        if isinstance(dataset_importer, BatchDatasetImporter):
-            # Batch dataset
-            # @todo enforce `expand_schema` parameter here
-            return dataset_importer.import_samples(dataset, tags=tags)
+    return dataset_importer
+
+
+def _build_parse_sample_fcn(
+    dataset, dataset_importer, label_field, tags, expand_schema
+):
+    if isinstance(dataset_importer, GenericSampleDatasetImporter):
+        # Generic sample dataset
 
         #
-        # Construct function to parse samples
+        # If the importer provides a sample field schema, apply it now
         #
+        # This is more efficient than adding samples with
+        # `expand_schema == True`. Also, ensures that all fields exist with
+        # the appropriate types, even if all of the imported samples have
+        # `None` values
+        #
+        if expand_schema and dataset_importer.has_sample_field_schema:
+            dataset._apply_field_schema(
+                dataset_importer.get_sample_field_schema()
+            )
+            expand_schema = False
 
-        if isinstance(dataset_importer, GenericSampleDatasetImporter):
-            # Generic sample dataset
+        def parse_sample(sample):
+            if tags:
+                sample.tags.extend(tags)
 
-            #
-            # If the importer provides a sample field schema, apply it now
-            #
-            # This is more efficient than adding samples with
-            # `expand_schema == True`. Also, ensures that all fields exist with
-            # the appropriate types, even if all of the imported samples have
-            # `None` values
-            #
-            if expand_schema and dataset_importer.has_sample_field_schema:
-                dataset._apply_field_schema(
-                    dataset_importer.get_sample_field_schema()
-                )
-                expand_schema = False
+            return sample
 
-            def parse_sample(sample):
-                if tags:
-                    sample.tags.extend(tags)
+    elif isinstance(dataset_importer, UnlabeledImageDatasetImporter):
+        # Unlabeled image dataset
 
-                return sample
+        # The schema never needs expanding when importing unlabeled samples
+        expand_schema = False
 
-        elif isinstance(dataset_importer, UnlabeledImageDatasetImporter):
-            # Unlabeled image dataset
+        def parse_sample(sample):
+            image_path, image_metadata = sample
+            return fos.Sample(
+                filepath=image_path, metadata=image_metadata, tags=tags,
+            )
 
-            # The schema never needs expanding when importing unlabeled samples
-            expand_schema = False
+    elif isinstance(dataset_importer, UnlabeledVideoDatasetImporter):
+        # Unlabeled video dataset
 
-            def parse_sample(sample):
-                image_path, image_metadata = sample
-                return fos.Sample(
-                    filepath=image_path, metadata=image_metadata, tags=tags,
-                )
+        # The schema never needs expanding when importing unlabeled samples
+        expand_schema = False
 
-        elif isinstance(dataset_importer, UnlabeledVideoDatasetImporter):
-            # Unlabeled video dataset
+        def parse_sample(sample):
+            video_path, video_metadata = sample
+            return fos.Sample(
+                filepath=video_path, metadata=video_metadata, tags=tags,
+            )
 
-            # The schema never needs expanding when importing unlabeled samples
-            expand_schema = False
+    elif isinstance(dataset_importer, LabeledImageDatasetImporter):
+        # Labeled image dataset
+
+        # Check if a single label field is being imported
+        try:
+            single_label_field = issubclass(
+                dataset_importer.label_cls, fol.Label
+            )
 
-            def parse_sample(sample):
-                video_path, video_metadata = sample
-                return fos.Sample(
-                    filepath=video_path, metadata=video_metadata, tags=tags,
+            if label_field is None:
+                raise ValueError(
+                    "A `label_field` must be provided when importing labeled "
+                    "image samples with a single label field"
                 )
+        except:
+            single_label_field = False
+
+        if expand_schema and single_label_field:
+            # This has the benefit of ensuring that `label_field` exists,
+            # even if all of the imported samples are unlabeled (i.e.,
+            # return labels that are all `None`)
+            dataset._ensure_label_field(
+                label_field, dataset_importer.label_cls
+            )
 
-        elif isinstance(dataset_importer, LabeledImageDatasetImporter):
-            # Labeled image dataset
+            # The schema now never needs expanding, because we already
+            # ensured that `label_field` exists, if necessary
+            expand_schema = False
 
-            # Check if a single label field is being imported
-            try:
-                single_label_field = issubclass(
-                    dataset_importer.label_cls, fol.Label
-                )
+        if label_field:
+            label_key = lambda k: label_field + "_" + k
+        else:
+            label_key = lambda k: k
 
-                if label_field is None:
-                    raise ValueError(
-                        "A `label_field` must be provided when importing "
-                        "labeled image samples with a single label field"
-                    )
-            except:
-                single_label_field = False
-
-            if expand_schema and single_label_field:
-                # This has the benefit of ensuring that `label_field` exists,
-                # even if all of the imported samples are unlabeled (i.e.,
-                # return labels that are all `None`)
-                dataset._ensure_label_field(
-                    label_field, dataset_importer.label_cls
+        def parse_sample(sample):
+            image_path, image_metadata, label = sample
+            sample = fos.Sample(
+                filepath=image_path, metadata=image_metadata, tags=tags,
+            )
+
+            if isinstance(label, dict):
+                sample.update_fields(
+                    {label_key(k): v for k, v in label.items()}
                 )
+            elif label is not None:
+                sample[label_field] = label
 
-                # The schema now never needs expanding, because we already
-                # ensured that `label_field` exists, if necessary
-                expand_schema = False
+            return sample
 
-            if label_field:
-                label_key = lambda k: label_field + "_" + k
-            else:
-                label_key = lambda k: k
+    elif isinstance(dataset_importer, LabeledVideoDatasetImporter):
+        # Labeled video dataset
 
-            def parse_sample(sample):
-                image_path, image_metadata, label = sample
-                sample = fos.Sample(
-                    filepath=image_path, metadata=image_metadata, tags=tags,
+        # Check if a single sample-level label field is being imported
+        try:
+            if (
+                issubclass(dataset_importer.label_cls, fol.Label)
+                and label_field is None
+            ):
+                raise ValueError(
+                    "A `label_field` must be provided when importing labeled "
+                    "video samples with a single sample-level field"
                 )
+        except:
+            pass
 
-                if isinstance(label, dict):
-                    sample.update_fields(
-                        {label_key(k): v for k, v in label.items()}
-                    )
-                elif label is not None:
-                    sample[label_field] = label
-
-                return sample
-
-        elif isinstance(dataset_importer, LabeledVideoDatasetImporter):
-            # Labeled video dataset
-
-            # Check if a single sample-level label field is being imported
-            try:
-                if (
-                    issubclass(dataset_importer.label_cls, fol.Label)
-                    and label_field is None
-                ):
-                    raise ValueError(
-                        "A `label_field` must be provided when importing "
-                        "labeled video samples with a single sample-level "
-                        "field"
-                    )
-            except:
-                pass
+        if label_field:
+            label_key = lambda k: label_field + "_" + k
+        else:
+            label_key = lambda k: k
 
-            if label_field:
-                label_key = lambda k: label_field + "_" + k
-            else:
-                label_key = lambda k: k
+        def parse_sample(sample):
+            video_path, video_metadata, label, frames = sample
+            sample = fos.Sample(
+                filepath=video_path, metadata=video_metadata, tags=tags,
+            )
 
-            def parse_sample(sample):
-                video_path, video_metadata, label, frames = sample
-                sample = fos.Sample(
-                    filepath=video_path, metadata=video_metadata, tags=tags,
+            if isinstance(label, dict):
+                sample.update_fields(
+                    {label_key(k): v for k, v in label.items()}
                 )
-
-                if isinstance(label, dict):
-                    sample.update_fields(
-                        {label_key(k): v for k, v in label.items()}
-                    )
-                elif label is not None:
-                    sample[label_field] = label
-
-                if frames is not None:
-                    sample.frames.merge(
-                        {
-                            frame_number: {
-                                label_key(fname): flabel
-                                for fname, flabel in frame_dict.items()
-                            }
-                            for frame_number, frame_dict in frames.items()
+            elif label is not None:
+                sample[label_field] = label
+
+            if frames is not None:
+                sample.frames.merge(
+                    {
+                        frame_number: {
+                            label_key(fname): flabel
+                            for fname, flabel in frame_dict.items()
                         }
-                    )
+                        for frame_number, frame_dict in frames.items()
+                    }
+                )
 
-                return sample
+            return sample
 
-        else:
-            raise ValueError(
-                "Unsupported DatasetImporter type %s" % type(dataset_importer)
-            )
+    else:
+        raise ValueError(
+            "Unsupported DatasetImporter type %s" % type(dataset_importer)
+        )
 
-        try:
-            num_samples = len(dataset_importer)
-        except:
-            num_samples = None
+    return parse_sample, expand_schema
 
-        # Import samples
-        samples = map(parse_sample, iter(dataset_importer))
-        sample_ids = dataset.add_samples(
-            samples, expand_schema=expand_schema, num_samples=num_samples
+
+def build_dataset_importer(
+    dataset_type, strip_none=True, warn_unused=True, **kwargs
+):
+    """Builds the :class:`DatasetImporter` instance for the given parameters.
+
+    Args:
+        dataset_type: the :class:`fiftyone.types.dataset_types.Dataset` type
+        strip_none (True): whether to exclude None-valued items from ``kwargs``
+        warn_unused (True): whether to issue warnings for any non-None unused
+            parameters encountered
+        **kwargs: keyword arguments to pass to the dataset importer's
+            constructor via ``DatasetImporter(**kwargs)``
+
+    Returns:
+        a tuple of:
+
+        -   the :class:`DatasetImporter` instance
+        -   a dict of unused keyword arguments
+    """
+    if dataset_type is None:
+        raise ValueError(
+            "You must provide a `dataset_type` in order to build a dataset "
+            "importer"
         )
 
-        # Load dataset info
-        if add_info and dataset_importer.has_dataset_info:
-            info = dataset_importer.get_dataset_info()
-            if info:
-                parse_info(dataset, info)
+    if inspect.isclass(dataset_type):
+        dataset_type = dataset_type()
 
-        # Load run results
-        if isinstance(dataset_importer, LegacyFiftyOneDatasetImporter):
-            dataset_importer.import_run_results(dataset)
+    dataset_importer_cls = dataset_type.get_dataset_importer_cls()
 
-        return sample_ids
+    if strip_none:
+        kwargs = {k: v for k, v in kwargs.items() if v is not None}
+
+    kwargs, unused_kwargs = fou.extract_kwargs_for_class(
+        dataset_importer_cls, kwargs
+    )
 
+    try:
+        dataset_importer = dataset_importer_cls(**kwargs)
+    except Exception as e:
+        raise ValueError(
+            "Failed to construct importer of type %s using the provided "
+            "parameters. See above for the error. You may need to supply "
+            "additional mandatory arguments. Please consult the documentation "
+            "of %s to learn more"
+            % (dataset_importer_cls, dataset_importer_cls)
+        ) from e
+
+    if warn_unused:
+        for key, value in unused_kwargs.items():
+            if value is not None:
+                logger.warning(
+                    "Ignoring unsupported parameter '%s' for importer type %s",
+                    key,
+                    dataset_importer_cls,
+                )
+
+    return dataset_importer, unused_kwargs
 
-def parse_info(dataset, info):
+
+def parse_dataset_info(dataset, info, overwrite=True):
     """Parses the info returned by :meth:`DatasetImporter.get_dataset_info` and
     stores it on the relevant properties of the dataset.
 
     Args:
         dataset: a :class:`fiftyone.core.dataset.Dataset`
         info: an info dict
+        overwrite (True): whether to overwrite existing dataset info fields
     """
     classes = info.pop("classes", None)
     if isinstance(classes, dict):
-        # Classes may already exist, so update rather than sett
-        dataset.classes.update(classes)
+        if overwrite:
+            dataset.classes.update(classes)
+        else:
+            _update_no_overwrite(dataset.classes, classes)
     elif isinstance(classes, list):
-        dataset.default_classes = classes
+        if overwrite or not dataset.default_classes:
+            dataset.default_classes = classes
 
     default_classes = info.pop("default_classes", None)
-    if default_classes:
-        dataset.default_classes = default_classes
+    if default_classes is not None:
+        if overwrite or not dataset.default_classes:
+            dataset.default_classes = default_classes
 
     mask_targets = info.pop("mask_targets", None)
-    if mask_targets:
-        # Mask targets may already exist, so update rather than set
-        dataset.mask_targets.update(dataset._parse_mask_targets(mask_targets))
+    if mask_targets is not None:
+        mask_targets = dataset._parse_mask_targets(mask_targets)
+        if overwrite:
+            dataset.mask_targets.update(mask_targets)
+        else:
+            _update_no_overwrite(dataset.mask_targets, mask_targets)
 
     default_mask_targets = info.pop("default_mask_targets", None)
-    if default_mask_targets:
-        dataset.default_mask_targets = dataset._parse_default_mask_targets(
-            default_mask_targets
-        )
+    if default_mask_targets is not None:
+        if overwrite or not dataset.default_mask_targets:
+            dataset.default_mask_targets = dataset._parse_default_mask_targets(
+                default_mask_targets
+            )
+
+    if overwrite:
+        dataset.info.update(info)
+    else:
+        _update_no_overwrite(dataset.info, info)
 
-    dataset.info.update(info)
     dataset.save()
 
 
+def _update_no_overwrite(d, dnew):
+    d.update({k: v for k, v in dnew.items() if k not in d})
+
+
+class ImportPathsMixin(object):
+    """Mixin for :class:`DatasetImporter` classes that provides convenience
+    methods for parsing the ``data_path`` and ``labels_path`` parameters
+    supported by many importers.
+    """
+
+    @staticmethod
+    def _parse_data_path(dataset_dir=None, data_path=None, default=None):
+        """Helper function that computes default values for the ``data_path``
+        parameter supported by many importers.
+        """
+        if data_path is None:
+            if dataset_dir is not None:
+                data_path = default
+
+        if data_path is not None:
+            data_path = os.path.expanduser(data_path)
+
+            if not os.path.isabs(data_path) and dataset_dir is not None:
+                dataset_dir = os.path.abspath(os.path.expanduser(dataset_dir))
+                data_path = os.path.join(dataset_dir, data_path)
+
+            if not os.path.exists(data_path):
+                if os.path.isfile(data_path + ".json"):
+                    data_path += ".json"
+
+        return data_path
+
+    @staticmethod
+    def _parse_labels_path(dataset_dir=None, labels_path=None, default=None):
+        """Helper function that computes default values for the ``labels_path``
+        parameter supported by many importers.
+        """
+        if labels_path is None:
+            if dataset_dir is not None:
+                labels_path = default
+
+        if labels_path is not None:
+            labels_path = os.path.expanduser(labels_path)
+
+            if not os.path.isabs(labels_path) and dataset_dir is not None:
+                dataset_dir = os.path.abspath(os.path.expanduser(dataset_dir))
+                labels_path = os.path.join(dataset_dir, labels_path)
+
+        return labels_path
+
+    @staticmethod
+    def _load_data_map(data_path, ignore_exts=False, recursive=False):
+        """Helper function that parses either a data directory or a data
+        manifest file into a UUID -> filepath map.
+        """
+        if not data_path:
+            data_map = {}
+        elif data_path.endswith(".json"):
+            if os.path.isfile(data_path):
+                data_map = etas.load_json(data_path)
+            else:
+                logger.warning("Data manifest '%s' does not exist", data_path)
+                data_map = {}
+        else:
+            if os.path.isdir(data_path):
+                if ignore_exts:
+                    to_uuid = lambda p: os.path.splitext(p)[0]
+                else:
+                    to_uuid = lambda p: p
+
+                data_map = {
+                    to_uuid(p): os.path.join(data_path, p)
+                    for p in etau.list_files(data_path, recursive=recursive)
+                }
+            else:
+                logger.warning("Data directory '%s' does not exist", data_path)
+                data_map = {}
+
+        return data_map
+
+
 class DatasetImporter(object):
     """Base interface for importing datasets stored on disk into FiftyOne.
 
+    Typically, dataset importers should implement the parameters documented on
+    this class, although this is not mandatory.
+
     See `this page <https://voxel51.com/docs/fiftyone/user_guide/dataset_creation/datasets.html#writing-a-custom-datasetimporter>`_
     for information about implementing/using dataset importers.
 
@@ -318,7 +670,8 @@ class DatasetImporter(object):
     .. automethod:: __next__
 
     Args:
-        dataset_dir: the dataset directory
+        dataset_dir (None): the dataset directory. This may be optional for
+            some importers
         shuffle (False): whether to randomly shuffle the order in which the
             samples are imported
         seed (None): a random seed to use when shuffling
@@ -327,9 +680,12 @@ class DatasetImporter(object):
     """
 
     def __init__(
-        self, dataset_dir, shuffle=False, seed=None, max_samples=None
+        self, dataset_dir=None, shuffle=False, seed=None, max_samples=None
     ):
-        self.dataset_dir = os.path.abspath(os.path.expanduser(dataset_dir))
+        if dataset_dir is not None:
+            dataset_dir = os.path.abspath(os.path.expanduser(dataset_dir))
+
+        self.dataset_dir = dataset_dir
         self.shuffle = shuffle
         self.seed = seed
         self.max_samples = max_samples
@@ -440,8 +796,12 @@ class BatchDatasetImporter(DatasetImporter):
     This interface allows for greater efficiency for import formats that
     handle aggregating over the samples themselves.
 
+    Typically, dataset importers should implement the parameters documented on
+    this class, although this is not mandatory.
+
     Args:
-        dataset_dir: the dataset directory
+        dataset_dir (None): the dataset directory. This may be optional for
+            some importers
         shuffle (False): whether to randomly shuffle the order in which the
             samples are imported
         seed (None): a random seed to use when shuffling
@@ -476,6 +836,9 @@ class GenericSampleDatasetImporter(DatasetImporter):
     """Interface for importing datasets that contain arbitrary
     :class:`fiftyone.core.sample.Sample` instances.
 
+    Typically, dataset importers should implement the parameters documented on
+    this class, although this is not mandatory.
+
     See `this page <https://voxel51.com/docs/fiftyone/user_guide/dataset_creation/datasets.html#writing-a-custom-datasetimporter>`_
     for information about implementing/using dataset importers.
 
@@ -483,7 +846,8 @@ class GenericSampleDatasetImporter(DatasetImporter):
     .. automethod:: __next__
 
     Args:
-        dataset_dir: the dataset directory
+        dataset_dir (None): the dataset directory. This may be optional for
+            some importers
         shuffle (False): whether to randomly shuffle the order in which the
             samples are imported
         seed (None): a random seed to use when shuffling
@@ -532,6 +896,9 @@ def get_sample_field_schema(self):
 class UnlabeledImageDatasetImporter(DatasetImporter):
     """Interface for importing datasets of unlabeled image samples.
 
+    Typically, dataset importers should implement the parameters documented on
+    this class, although this is not mandatory.
+
     See `this page <https://voxel51.com/docs/fiftyone/user_guide/dataset_creation/datasets.html#writing-a-custom-datasetimporter>`_
     for information about implementing/using dataset importers.
 
@@ -539,7 +906,8 @@ class UnlabeledImageDatasetImporter(DatasetImporter):
     .. automethod:: __next__
 
     Args:
-        dataset_dir: the dataset directory
+        dataset_dir (None): the dataset directory. This may be optional for
+            some importers
         shuffle (False): whether to randomly shuffle the order in which the
             samples are imported
         seed (None): a random seed to use when shuffling
@@ -574,6 +942,9 @@ def has_image_metadata(self):
 class UnlabeledVideoDatasetImporter(DatasetImporter):
     """Interface for importing datasets of unlabeled video samples.
 
+    Typically, dataset importers should implement the parameters documented on
+    this class, although this is not mandatory.
+
     See `this page <https://voxel51.com/docs/fiftyone/user_guide/dataset_creation/datasets.html#writing-a-custom-datasetimporter>`_
     for information about implementing/using dataset importers.
 
@@ -581,7 +952,8 @@ class UnlabeledVideoDatasetImporter(DatasetImporter):
     .. automethod:: __next__
 
     Args:
-        dataset_dir: the dataset directory
+        dataset_dir (None): the dataset directory. This may be optional for
+            some importers
         shuffle (False): whether to randomly shuffle the order in which the
             samples are imported
         seed (None): a random seed to use when shuffling
@@ -616,6 +988,9 @@ def has_video_metadata(self):
 class LabeledImageDatasetImporter(DatasetImporter):
     """Interface for importing datasets of labeled image samples.
 
+    Typically, dataset importers should implement the parameters documented on
+    this class, although this is not mandatory.
+
     See `this page <https://voxel51.com/docs/fiftyone/user_guide/dataset_creation/datasets.html#writing-a-custom-datasetimporter>`_
     for information about implementing/using dataset importers.
 
@@ -623,8 +998,8 @@ class LabeledImageDatasetImporter(DatasetImporter):
     .. automethod:: __next__
 
     Args:
-        dataset_dir: the dataset directory
-        skip_unlabeled (False): whether to skip unlabeled images when importing
+        dataset_dir (None): the dataset directory. This may be optional for
+            some importers
         shuffle (False): whether to randomly shuffle the order in which the
             samples are imported
         seed (None): a random seed to use when shuffling
@@ -632,20 +1007,6 @@ class LabeledImageDatasetImporter(DatasetImporter):
             all samples are imported
     """
 
-    def __init__(
-        self,
-        dataset_dir,
-        skip_unlabeled=False,
-        shuffle=False,
-        seed=None,
-        max_samples=None,
-    ):
-        super().__init__(dataset_dir)
-        self.skip_unlabeled = skip_unlabeled
-        self.shuffle = shuffle
-        self.seed = seed
-        self.max_samples = max_samples
-
     def __next__(self):
         """Returns information about the next sample in the dataset.
 
@@ -694,15 +1055,18 @@ def label_cls(self):
 class LabeledVideoDatasetImporter(DatasetImporter):
     """Interface for importing datasets of labeled video samples.
 
-    .. automethod:: __len__
-    .. automethod:: __next__
+    Typically, dataset importers should implement the parameters documented on
+    this class, although this is not mandatory.
 
     See `this page <https://voxel51.com/docs/fiftyone/user_guide/dataset_creation/datasets.html#writing-a-custom-datasetimporter>`_
     for information about implementing/using dataset importers.
 
+    .. automethod:: __len__
+    .. automethod:: __next__
+
     Args:
-        dataset_dir: the dataset directory
-        skip_unlabeled (False): whether to skip unlabeled videos when importing
+        dataset_dir (None): the dataset directory. This may be optional for
+            some importers
         shuffle (False): whether to randomly shuffle the order in which the
             samples are imported
         seed (None): a random seed to use when shuffling
@@ -710,20 +1074,6 @@ class LabeledVideoDatasetImporter(DatasetImporter):
             all samples are imported
     """
 
-    def __init__(
-        self,
-        dataset_dir,
-        skip_unlabeled=False,
-        shuffle=False,
-        seed=None,
-        max_samples=None,
-    ):
-        super().__init__(dataset_dir)
-        self.skip_unlabeled = skip_unlabeled
-        self.shuffle = shuffle
-        self.seed = seed
-        self.max_samples = max_samples
-
     def __next__(self):
         """Returns information about the next sample in the dataset.
 
@@ -825,8 +1175,12 @@ def __init__(
         self, dataset_dir, shuffle=False, seed=None, max_samples=None
     ):
         super().__init__(
-            dataset_dir, shuffle=shuffle, seed=seed, max_samples=max_samples
+            dataset_dir=dataset_dir,
+            shuffle=shuffle,
+            seed=seed,
+            max_samples=max_samples,
         )
+
         self._metadata = None
         self._eval_dir = None
         self._brain_dir = None
@@ -941,7 +1295,8 @@ def import_run_results(self, sample_collection):
             dataset._doc.save()
 
     @staticmethod
-    def get_classes(dataset_dir):
+    def _get_classes(dataset_dir):
+        # Used only by dataset zoo
         metadata_path = os.path.join(dataset_dir, "metadata.json")
         if not os.path.isfile(metadata_path):
             return None
@@ -959,12 +1314,9 @@ def get_classes(dataset_dir):
         return metadata.get("info", {}).get("classes", None)
 
     @staticmethod
-    def get_num_samples(dataset_dir):
-        data_dir = os.path.join(dataset_dir, "data")
-        if not os.path.isdir(data_dir):
-            return 0
-
-        return len(etau.list_files(data_dir))
+    def _get_num_samples(dataset_dir):
+        # Used only by dataset zoo
+        return len(etau.list_files(os.path.join(dataset_dir, "data")))
 
     def _import_frame_labels(self, sample, labels_path):
         frames_map = etas.load_json(labels_path).get("frames", {})
@@ -980,29 +1332,33 @@ class FiftyOneDatasetImporter(BatchDatasetImporter):
 
     Args:
         dataset_dir: the dataset directory
+        rel_dir (None): a relative directory to prepend to the ``filepath``
+            of each sample if the filepath is not absolute. This path is
+            converted to an absolute path (if necessary) via
+            ``os.path.abspath(os.path.expanduser(rel_dir))``
         shuffle (False): whether to randomly shuffle the order in which the
             samples are imported
         seed (None): a random seed to use when shuffling
         max_samples (None): a maximum number of samples to import. By default,
             all samples are imported
-        rel_dir (None): a relative directory to prepend to the ``filepath``
-            of each sample if the filepath is not absolute (begins with a
-            path separator). The path is converted to an absolute path (if
-            necessary) via ``os.path.abspath(os.path.expanduser(rel_dir))``
     """
 
     def __init__(
         self,
         dataset_dir,
+        rel_dir=None,
         shuffle=False,
         seed=None,
         max_samples=None,
-        rel_dir=None,
     ):
         super().__init__(
-            dataset_dir, shuffle=shuffle, seed=seed, max_samples=max_samples
+            dataset_dir=dataset_dir,
+            shuffle=shuffle,
+            seed=seed,
+            max_samples=max_samples,
         )
         self.rel_dir = rel_dir
+
         self._data_dir = None
         self._eval_dir = None
         self._brain_dir = None
@@ -1160,7 +1516,8 @@ def _import_samples(self, dataset, dataset_dict, tags=None):
         return sample_ids
 
     @staticmethod
-    def get_classes(dataset_dir):
+    def _get_classes(dataset_dir):
+        # Used only by dataset zoo
         metadata_path = os.path.join(dataset_dir, "metadata.json")
         metadata = etas.load_json(metadata_path)
 
@@ -1175,7 +1532,8 @@ def get_classes(dataset_dir):
         return metadata.get("info", {}).get("classes", None)
 
     @staticmethod
-    def get_num_samples(dataset_dir):
+    def _get_num_samples(dataset_dir):
+        # Used only by dataset zoo
         samples_path = os.path.join(dataset_dir, "samples.json")
         samples = etas.load_json(samples_path).get("samples", [])
         return len(samples)
@@ -1251,7 +1609,10 @@ def __init__(
         max_samples=None,
     ):
         super().__init__(
-            dataset_dir, shuffle=shuffle, seed=seed, max_samples=max_samples
+            dataset_dir=dataset_dir,
+            shuffle=shuffle,
+            seed=seed,
+            max_samples=max_samples,
         )
         self.recursive = recursive
         self.compute_metadata = compute_metadata
@@ -1294,8 +1655,9 @@ def setup(self):
         self._num_samples = len(self._filepaths)
 
     @staticmethod
-    def get_num_samples(dataset_dir, recursive=True):
-        filepaths = etau.list_files(dataset_dir, recursive=recursive)
+    def _get_num_samples(dataset_dir):
+        # Used only by dataset zoo
+        filepaths = etau.list_files(dataset_dir, recursive=True)
         filepaths = [p for p in filepaths if etai.is_image_mime_type(p)]
         return len(filepaths)
 
@@ -1329,7 +1691,10 @@ def __init__(
         max_samples=None,
     ):
         super().__init__(
-            dataset_dir, shuffle=shuffle, seed=seed, max_samples=max_samples
+            dataset_dir=dataset_dir,
+            shuffle=shuffle,
+            seed=seed,
+            max_samples=max_samples,
         )
         self.recursive = recursive
         self.compute_metadata = compute_metadata
@@ -1372,13 +1737,16 @@ def setup(self):
         self._num_samples = len(self._filepaths)
 
     @staticmethod
-    def get_num_samples(dataset_dir, recursive=True):
-        filepaths = etau.list_files(dataset_dir, recursive=recursive)
+    def _get_num_samples(dataset_dir):
+        # Used only by dataset zoo
+        filepaths = etau.list_files(dataset_dir, recursive=True)
         filepaths = [p for p in filepaths if etav.is_video_mime_type(p)]
         return len(filepaths)
 
 
-class FiftyOneImageClassificationDatasetImporter(LabeledImageDatasetImporter):
+class FiftyOneImageClassificationDatasetImporter(
+    LabeledImageDatasetImporter, ImportPathsMixin
+):
     """Importer for image classification datasets stored on disk in FiftyOne's
     default format.
 
@@ -1386,11 +1754,35 @@ class FiftyOneImageClassificationDatasetImporter(LabeledImageDatasetImporter):
     for format details.
 
     Args:
-        dataset_dir: the dataset directory
+        dataset_dir (None): the dataset directory
+        data_path (None): an optional parameter that enables explicit control
+            over the location of the media. Can be any of the following:
+
+            -   a folder name like ``"data"`` or ``"data"/`` specifying a
+                subfolder of ``dataset_dir`` where the media files reside
+            -   an absolute directory path where the media files reside. In
+                this case, the ``dataset_dir`` has no effect on the location of
+                the data
+            -   a filename like ``"data.json"`` specifying the filename of the
+                JSON data manifest file in ``dataset_dir``
+            -   an absolute filepath specifying the location of the JSON data
+                manifest. In this case, ``dataset_dir`` has no effect on the
+                location of the data
+
+            If None, this parameter will default to whichever of ``data/`` or
+            ``data.json`` exists in the dataset directory
+        labels_path (None): an optional parameter that enables explicit control
+            over the location of the labels. Can be any of the following:
+
+            -   a filename like ``"labels.json"`` specifying the location of
+                the labels in ``dataset_dir``
+            -   an absolute filepath to the labels. In this case,
+                ``dataset_dir`` has no effect on the location of the labels
+
+            If None, the parameter will default to ``labels.json``
         compute_metadata (False): whether to produce
             :class:`fiftyone.core.metadata.ImageMetadata` instances for each
             image when importing
-        skip_unlabeled (False): whether to skip unlabeled images when importing
         shuffle (False): whether to randomly shuffle the order in which the
             samples are imported
         seed (None): a random seed to use when shuffling
@@ -1400,21 +1792,35 @@ class FiftyOneImageClassificationDatasetImporter(LabeledImageDatasetImporter):
 
     def __init__(
         self,
-        dataset_dir,
+        dataset_dir=None,
+        data_path=None,
+        labels_path=None,
         compute_metadata=False,
-        skip_unlabeled=False,
         shuffle=False,
         seed=None,
         max_samples=None,
     ):
+        data_path = self._parse_data_path(
+            dataset_dir=dataset_dir, data_path=data_path, default="data/",
+        )
+
+        labels_path = self._parse_labels_path(
+            dataset_dir=dataset_dir,
+            labels_path=labels_path,
+            default="labels.json",
+        )
+
         super().__init__(
-            dataset_dir,
-            skip_unlabeled=skip_unlabeled,
+            dataset_dir=dataset_dir,
             shuffle=shuffle,
             seed=seed,
             max_samples=max_samples,
         )
+
+        self.data_path = data_path
+        self.labels_path = labels_path
         self.compute_metadata = compute_metadata
+
         self._classes = None
         self._sample_parser = None
         self._image_paths_map = None
@@ -1461,15 +1867,12 @@ def label_cls(self):
     def setup(self):
         self._sample_parser = FiftyOneImageClassificationSampleParser()
 
-        data_dir = os.path.join(self.dataset_dir, "data")
-        self._image_paths_map = {
-            os.path.splitext(os.path.basename(p))[0]: p
-            for p in etau.list_files(data_dir, abs_paths=True)
-        }
+        self._image_paths_map = self._load_data_map(
+            self.data_path, ignore_exts=True
+        )
 
-        labels_path = os.path.join(self.dataset_dir, "labels.json")
-        if os.path.isfile(labels_path):
-            labels = etas.load_json(labels_path)
+        if self.labels_path is not None and os.path.isfile(self.labels_path):
+            labels = etas.load_json(self.labels_path)
         else:
             labels = {}
 
@@ -1477,10 +1880,6 @@ def setup(self):
         self._sample_parser.classes = self._classes
 
         self._labels_map = labels.get("labels", {})
-        if self.skip_unlabeled:
-            self._labels_map = {
-                k: v for k, v in self._labels_map.items() if v is not None
-            }
 
         uuids = sorted(self._labels_map.keys())
         self._uuids = self._preprocess_list(uuids)
@@ -1491,13 +1890,15 @@ def get_dataset_info(self):
         return {"classes": self._classes}
 
     @staticmethod
-    def get_classes(dataset_dir):
+    def _get_classes(dataset_dir):
+        # Used only by dataset zoo
         labels_path = os.path.join(dataset_dir, "labels.json")
         labels = etas.read_json(labels_path)
         return labels.get("classes", None)
 
     @staticmethod
-    def get_num_samples(dataset_dir):
+    def _get_num_samples(dataset_dir):
+        # Used only by dataset zoo
         labels_path = os.path.join(dataset_dir, "labels.json")
         labels = etas.read_json(labels_path)
         return len(labels.get("labels", {}))
@@ -1514,7 +1915,8 @@ class ImageClassificationDirectoryTreeImporter(LabeledImageDatasetImporter):
         compute_metadata (False): whether to produce
             :class:`fiftyone.core.metadata.ImageMetadata` instances for each
             image when importing
-        skip_unlabeled (False): whether to skip unlabeled images when importing
+        unlabeled ("_unlabeled"): the name of the subdirectory containing
+            unlabeled images
         shuffle (False): whether to randomly shuffle the order in which the
             samples are imported
         seed (None): a random seed to use when shuffling
@@ -1526,19 +1928,21 @@ def __init__(
         self,
         dataset_dir,
         compute_metadata=False,
-        skip_unlabeled=False,
+        unlabeled="_unlabeled",
         shuffle=False,
         seed=None,
         max_samples=None,
     ):
         super().__init__(
-            dataset_dir,
-            skip_unlabeled=skip_unlabeled,
+            dataset_dir=dataset_dir,
             shuffle=shuffle,
             seed=seed,
             max_samples=max_samples,
         )
+
         self.compute_metadata = compute_metadata
+        self.unlabeled = unlabeled
+
         self._classes = None
         self._samples = None
         self._iter_samples = None
@@ -1559,7 +1963,8 @@ def __next__(self):
         else:
             image_metadata = None
 
-        label = fol.Classification(label=label)
+        if label is not None:
+            label = fol.Classification(label=label)
 
         return image_path, image_metadata, label
 
@@ -1583,10 +1988,7 @@ def setup(self):
             if label.startswith("."):
                 continue
 
-            if label == "_unlabeled":
-                if self.skip_unlabeled:
-                    continue
-
+            if label == self.unlabeled:
                 label = None
             else:
                 classes.add(label)
@@ -1602,11 +2004,13 @@ def get_dataset_info(self):
         return {"classes": self._classes}
 
     @staticmethod
-    def get_classes(dataset_dir):
+    def _get_classes(dataset_dir):
+        # Used only by dataset zoo
         return sorted(etau.list_subdirs(dataset_dir))
 
     @staticmethod
-    def get_num_samples(dataset_dir):
+    def _get_num_samples(dataset_dir):
+        # Used only by dataset zoo
         num_samples = 0
         for class_dir in etau.list_subdirs(dataset_dir, abs_paths=True):
             num_samples += len(etau.list_files(class_dir))
@@ -1625,7 +2029,8 @@ class VideoClassificationDirectoryTreeImporter(LabeledVideoDatasetImporter):
         compute_metadata (False): whether to produce
             :class:`fiftyone.core.metadata.VideoMetadata` instances for each
             video when importing
-        skip_unlabeled (False): whether to skip unlabeled videos when importing
+        unlabeled ("_unlabeled"): the name of the subdirectory containing
+            unlabeled images
         shuffle (False): whether to randomly shuffle the order in which the
             samples are imported
         seed (None): a random seed to use when shuffling
@@ -1637,19 +2042,21 @@ def __init__(
         self,
         dataset_dir,
         compute_metadata=False,
-        skip_unlabeled=False,
+        unlabeled="_unlabeled",
         shuffle=False,
         seed=None,
         max_samples=None,
     ):
         super().__init__(
-            dataset_dir,
-            skip_unlabeled=skip_unlabeled,
+            dataset_dir=dataset_dir,
             shuffle=shuffle,
             seed=seed,
             max_samples=max_samples,
         )
+
         self.compute_metadata = compute_metadata
+        self.unlabeled = unlabeled
+
         self._classes = None
         self._samples = None
         self._iter_samples = None
@@ -1670,7 +2077,8 @@ def __next__(self):
         else:
             video_metadata = None
 
-        label = fol.Classification(label=label)
+        if label is not None:
+            label = fol.Classification(label=label)
 
         return video_path, video_metadata, label, None
 
@@ -1698,10 +2106,7 @@ def setup(self):
             if label.startswith("."):
                 continue
 
-            if label == "_unlabeled":
-                if self.skip_unlabeled:
-                    continue
-
+            if label == self.unlabeled:
                 label = None
             else:
                 classes.add(label)
@@ -1717,11 +2122,13 @@ def get_dataset_info(self):
         return {"classes": self._classes}
 
     @staticmethod
-    def get_classes(dataset_dir):
+    def _get_classes(dataset_dir):
+        # Used only by dataset zoo
         return sorted(etau.list_subdirs(dataset_dir))
 
     @staticmethod
-    def get_num_samples(dataset_dir):
+    def _get_num_samples(dataset_dir):
+        # Used only by dataset zoo
         num_samples = 0
         for class_dir in etau.list_subdirs(dataset_dir, abs_paths=True):
             num_samples += len(etau.list_files(class_dir))
@@ -1729,7 +2136,9 @@ def get_num_samples(dataset_dir):
         return num_samples
 
 
-class FiftyOneImageDetectionDatasetImporter(LabeledImageDatasetImporter):
+class FiftyOneImageDetectionDatasetImporter(
+    LabeledImageDatasetImporter, ImportPathsMixin
+):
     """Importer for image detection datasets stored on disk in FiftyOne's
     default format.
 
@@ -1737,11 +2146,35 @@ class FiftyOneImageDetectionDatasetImporter(LabeledImageDatasetImporter):
     format details.
 
     Args:
-        dataset_dir: the dataset directory
+        dataset_dir (None): the dataset directory
+        data_path (None): an optional parameter that enables explicit control
+            over the location of the media. Can be any of the following:
+
+            -   a folder name like ``"data"`` or ``"data"/`` specifying a
+                subfolder of ``dataset_dir`` where the media files reside
+            -   an absolute directory path where the media files reside. In
+                this case, the ``dataset_dir`` has no effect on the location of
+                the data
+            -   a filename like ``"data.json"`` specifying the filename of the
+                JSON data manifest file in ``dataset_dir``
+            -   an absolute filepath specifying the location of the JSON data
+                manifest. In this case, ``dataset_dir`` has no effect on the
+                location of the data
+
+            If None, this parameter will default to whichever of ``data/`` or
+            ``data.json`` exists in the dataset directory
+        labels_path (None): an optional parameter that enables explicit control
+            over the location of the labels. Can be any of the following:
+
+            -   a filename like ``"labels.json"`` specifying the location of
+                the labels in ``dataset_dir``
+            -   an absolute filepath to the labels. In this case,
+                ``dataset_dir`` has no effect on the location of the labels
+
+            If None, the parameter will default to ``labels.json``
         compute_metadata (False): whether to produce
             :class:`fiftyone.core.metadata.ImageMetadata` instances for each
             image when importing
-        skip_unlabeled (False): whether to skip unlabeled images when importing
         shuffle (False): whether to randomly shuffle the order in which the
             samples are imported
         seed (None): a random seed to use when shuffling
@@ -1751,21 +2184,35 @@ class FiftyOneImageDetectionDatasetImporter(LabeledImageDatasetImporter):
 
     def __init__(
         self,
-        dataset_dir,
+        dataset_dir=None,
+        data_path=None,
+        labels_path=None,
         compute_metadata=False,
-        skip_unlabeled=False,
         shuffle=False,
         seed=None,
         max_samples=None,
     ):
+        data_path = self._parse_data_path(
+            dataset_dir=dataset_dir, data_path=data_path, default="data/",
+        )
+
+        labels_path = self._parse_labels_path(
+            dataset_dir=dataset_dir,
+            labels_path=labels_path,
+            default="labels.json",
+        )
+
         super().__init__(
-            dataset_dir,
-            skip_unlabeled=skip_unlabeled,
+            dataset_dir=dataset_dir,
             shuffle=shuffle,
             seed=seed,
             max_samples=max_samples,
         )
+
+        self.data_path = data_path
+        self.labels_path = labels_path
         self.compute_metadata = compute_metadata
+
         self._classes = None
         self._sample_parser = None
         self._image_paths_map = None
@@ -1816,27 +2263,18 @@ def label_cls(self):
     def setup(self):
         self._sample_parser = FiftyOneImageDetectionSampleParser()
 
-        data_dir = os.path.join(self.dataset_dir, "data")
-        self._image_paths_map = {
-            os.path.splitext(os.path.basename(p))[0]: p
-            for p in etau.list_files(data_dir, abs_paths=True)
-        }
+        self._image_paths_map = self._load_data_map(
+            self.data_path, ignore_exts=True
+        )
 
-        labels_path = os.path.join(self.dataset_dir, "labels.json")
-        if os.path.isfile(labels_path):
-            labels = etas.load_json(labels_path)
+        if self.labels_path is not None and os.path.isfile(self.labels_path):
+            labels = etas.load_json(self.labels_path)
         else:
             labels = {}
 
         self._classes = labels.get("classes", None)
         self._sample_parser.classes = self._classes
-
         self._labels_map = labels.get("labels", {})
-        if self.skip_unlabeled:
-            self._labels_map = {
-                k: v for k, v in self._labels_map.items() if v is not None
-            }
-
         self._has_labels = any(self._labels_map.values())
 
         uuids = sorted(self._labels_map.keys())
@@ -1847,37 +2285,63 @@ def get_dataset_info(self):
         return {"classes": self._classes}
 
     @staticmethod
-    def get_classes(dataset_dir):
+    def _get_classes(dataset_dir):
+        # Used only by dataset zoo
         labels_path = os.path.join(dataset_dir, "labels.json")
         labels = etas.read_json(labels_path)
         return labels.get("classes", None)
 
     @staticmethod
-    def get_num_samples(dataset_dir):
+    def _get_num_samples(dataset_dir):
+        # Used only by dataset zoo
         labels_path = os.path.join(dataset_dir, "labels.json")
         labels = etas.read_json(labels_path)
         return len(labels.get("labels", {}))
 
 
-class ImageSegmentationDirectoryImporter(LabeledImageDatasetImporter):
+class ImageSegmentationDirectoryImporter(
+    LabeledImageDatasetImporter, ImportPathsMixin
+):
     """Importer for image segmentation datasets stored on disk.
 
     See :class:`fiftyone.types.dataset_types.ImageSegmentationDirectory` for
     format details.
 
     Args:
-        dataset_dir: the dataset directory. If ``data_path`` and
-            ``labels_path`` are both absolute paths, this value has no effect
-        data_path ("data"): either the name of the subfolder in ``dataset_dir``
-            or an absolute path to the directory of images
-        labels_path ("labels"): either the name of the subfolder in
-            ``dataset_dir`` or an absolute path to the directory of labels
+        dataset_dir (None): the dataset directory
+        data_path (None): an optional parameter that enables explicit control
+            over the location of the media. Can be any of the following:
+
+            -   a folder name like ``"data"`` or ``"data"/`` specifying a
+                subfolder of ``dataset_dir`` where the media files reside
+            -   an absolute directory path where the media files reside. In
+                this case, the ``dataset_dir`` has no effect on the location of
+                the data
+            -   a filename like ``"data.json"`` specifying the filename of the
+                JSON data manifest file in ``dataset_dir``
+            -   an absolute filepath specifying the location of the JSON data
+                manifest. In this case, ``dataset_dir`` has no effect on the
+                location of the data
+
+            If None, this parameter will default to whichever of ``data/`` or
+            ``data.json`` exists in the dataset directory
+        labels_path (None): an optional parameter that enables explicit control
+            over the location of the labels. Can be any of the following:
+
+            -   a folder name like ``"labels"`` or ``"labels/"`` specifying the
+                location of the labels in ``dataset_dir``
+            -   an absolute filepath to the labels. In this case,
+                ``dataset_dir`` has no effect on the location of the labels
+
+            If None, the parameter will default to ``labels/``
         force_grayscale (False): whether to load RGB masks as grayscale by
             storing only the first channel
         compute_metadata (False): whether to produce
             :class:`fiftyone.core.metadata.ImageMetadata` instances for each
             image when importing
-        skip_unlabeled (False): whether to skip unlabeled images when importing
+        include_all_data (False): whether to generate samples for all images in
+            the data directory (True) rather than only creating samples for
+            images with masks (False)
         shuffle (False): whether to randomly shuffle the order in which the
             samples are imported
         seed (None): a random seed to use when shuffling
@@ -1887,30 +2351,39 @@ class ImageSegmentationDirectoryImporter(LabeledImageDatasetImporter):
 
     def __init__(
         self,
-        dataset_dir,
-        data_path="data",
-        labels_path="labels",
+        dataset_dir=None,
+        data_path=None,
+        labels_path=None,
         compute_metadata=False,
         force_grayscale=False,
-        skip_unlabeled=False,
+        include_all_data=False,
         shuffle=False,
         seed=None,
         max_samples=None,
     ):
+        data_path = self._parse_data_path(
+            dataset_dir=dataset_dir, data_path=data_path, default="data/",
+        )
+
+        labels_path = self._parse_labels_path(
+            dataset_dir=dataset_dir,
+            labels_path=labels_path,
+            default="labels/",
+        )
+
         super().__init__(
-            dataset_dir,
-            skip_unlabeled=skip_unlabeled,
+            dataset_dir=dataset_dir,
             shuffle=shuffle,
             seed=seed,
             max_samples=max_samples,
         )
+
         self.data_path = data_path
         self.labels_path = labels_path
         self.force_grayscale = force_grayscale
         self.compute_metadata = compute_metadata
+        self.include_all_data = include_all_data
 
-        self._data_path = None
-        self._labels_path = None
         self._image_paths_map = None
         self._mask_paths_map = None
         self._uuids = None
@@ -1956,46 +2429,27 @@ def label_cls(self):
         return fol.Segmentation
 
     def setup(self):
-        if os.path.isabs(self.data_path):
-            data_dir = self.data_path
-        else:
-            data_dir = os.path.join(self.dataset_dir, self.data_path)
-
-        if os.path.isabs(self.labels_path):
-            labels_dir = self.labels_path
-        else:
-            labels_dir = os.path.join(self.dataset_dir, self.labels_path)
-
-        self._image_paths_map = {
-            os.path.splitext(os.path.basename(p))[0]: p
-            for p in etau.list_files(data_dir, abs_paths=True)
-        }
+        self._image_paths_map = self._load_data_map(
+            self.data_path, ignore_exts=True
+        )
 
         self._mask_paths_map = {
             os.path.splitext(os.path.basename(p))[0]: p
-            for p in etau.list_files(labels_dir, abs_paths=True)
+            for p in etau.list_files(self.labels_path, abs_paths=True)
         }
 
-        if self.skip_unlabeled:
-            uuids = sorted(self._mask_paths_map.keys())
-        else:
-            uuids = sorted(self._image_paths_map.keys())
+        uuids = set(self._mask_paths_map.keys())
 
-        self._uuids = self._preprocess_list(uuids)
+        if self.include_all_data:
+            uuids.update(self._image_paths_map.keys())
+
+        self._uuids = self._preprocess_list(sorted(uuids))
         self._num_samples = len(self._uuids)
 
     @staticmethod
-    def get_num_samples(dataset_dir=None, data_path="data"):
-        if os.path.isabs(data_path):
-            data_dir = data_path
-        elif dataset_dir is not None:
-            data_dir = os.path.join(dataset_dir, data_path)
-        else:
-            raise ValueError(
-                "Either `dataset_dir` or `data_path` must be provided"
-            )
-
-        return len(etau.list_files(data_dir))
+    def _get_num_samples(dataset_dir):
+        # Used only by dataset zoo
+        return len(etau.list_files(os.path.join(dataset_dir, "data")))
 
 
 class FiftyOneImageLabelsDatasetImporter(LabeledImageDatasetImporter):
@@ -2018,7 +2472,6 @@ class FiftyOneImageLabelsDatasetImporter(LabeledImageDatasetImporter):
             :class:`fiftyone.core.labels.Classifications` instance
         skip_non_categorical (False): whether to skip non-categorical frame
             attributes (True) or cast them to strings (False)
-        skip_unlabeled (False): whether to skip unlabeled images when importing
         max_samples (None): a maximum number of samples to import. By default,
             all samples are imported
     """
@@ -2031,21 +2484,18 @@ def __init__(
         labels_dict=None,
         multilabel=False,
         skip_non_categorical=False,
-        skip_unlabeled=False,
         max_samples=None,
-        **kwargs
     ):
-        for arg in kwargs:
-            logger.warning("Ignoring unsupported parameter '%s'", arg)
-
         super().__init__(
-            dataset_dir, skip_unlabeled=skip_unlabeled, max_samples=max_samples
+            dataset_dir=dataset_dir, max_samples=max_samples,
         )
+
         self.compute_metadata = compute_metadata
         self.prefix = prefix
         self.labels_dict = labels_dict
         self.multilabel = multilabel
         self.skip_non_categorical = skip_non_categorical
+
         self._description = None
         self._sample_parser = None
         self._labeled_dataset = None
@@ -2071,11 +2521,11 @@ def __next__(self):
         ):
             raise StopIteration
 
-        image_path, label = self._parse_next_sample()
+        sample = next(self._iter_labeled_dataset)
 
-        if self.skip_unlabeled:
-            while label is None:
-                image_path, label = self._parse_next_sample()
+        self._sample_parser.with_sample(sample)
+        image_path = self._sample_parser.get_image_path()
+        label = self._sample_parser.get_label()
 
         if self.compute_metadata:
             image_metadata = fom.ImageMetadata.build_for(image_path)
@@ -2085,15 +2535,6 @@ def __next__(self):
         self._num_imported += 1
         return image_path, image_metadata, label
 
-    def _parse_next_sample(self):
-        sample = next(self._iter_labeled_dataset)
-
-        self._sample_parser.with_sample(sample)
-        image_path = self._sample_parser.get_image_path()
-        label = self._sample_parser.get_label()
-
-        return image_path, label
-
     @property
     def has_dataset_info(self):
         return bool(self._description)
@@ -2129,7 +2570,8 @@ def get_dataset_info(self):
         return {"description": self._description}
 
     @staticmethod
-    def get_num_samples(dataset_dir):
+    def _get_num_samples(dataset_dir):
+        # Used only by dataset zoo
         return len(etads.load_dataset(dataset_dir))
 
 
@@ -2153,7 +2595,6 @@ class FiftyOneVideoLabelsDatasetImporter(LabeledVideoDatasetImporter):
             :class:`fiftyone.core.labels.Classifications` instance
         skip_non_categorical (False): whether to skip non-categorical frame
             attributes (True) or cast them to strings (False)
-        skip_unlabeled (False): whether to skip unlabeled videos when importing
         max_samples (None): a maximum number of samples to import. By default,
             all samples are imported
     """
@@ -2166,21 +2607,16 @@ def __init__(
         labels_dict=None,
         multilabel=False,
         skip_non_categorical=False,
-        skip_unlabeled=False,
         max_samples=None,
-        **kwargs
     ):
-        for arg in kwargs:
-            logger.warning("Ignoring unsupported parameter '%s'", arg)
+        super().__init__(dataset_dir=dataset_dir, max_samples=max_samples)
 
-        super().__init__(
-            dataset_dir, skip_unlabeled=skip_unlabeled, max_samples=max_samples
-        )
         self.compute_metadata = compute_metadata
         self.prefix = prefix
         self.labels_dict = labels_dict
         self.multilabel = multilabel
         self.skip_non_categorical = skip_non_categorical
+
         self._description = None
         self._sample_parser = None
         self._labeled_dataset = None
@@ -2206,11 +2642,11 @@ def __next__(self):
         ):
             raise StopIteration
 
-        video_path, frames = self._parse_next_sample()
+        sample = next(self._iter_labeled_dataset)
 
-        if self.skip_unlabeled:
-            while frames is None:
-                video_path, frames = self._parse_next_sample()
+        self._sample_parser.with_sample(sample)
+        video_path = self._sample_parser.get_video_path()
+        frames = self._sample_parser.get_frame_labels()
 
         if self.compute_metadata:
             video_metadata = fom.VideoMetadata.build_for(video_path)
@@ -2220,15 +2656,6 @@ def __next__(self):
         self._num_imported += 1
         return video_path, video_metadata, None, frames
 
-    def _parse_next_sample(self):
-        sample = next(self._iter_labeled_dataset)
-
-        self._sample_parser.with_sample(sample)
-        video_path = self._sample_parser.get_video_path()
-        frames = self._sample_parser.get_frame_labels()
-
-        return video_path, frames
-
     @property
     def has_dataset_info(self):
         return bool(self._description)
@@ -2263,7 +2690,8 @@ def get_dataset_info(self):
         return {"description": self._description}
 
     @staticmethod
-    def get_num_samples(dataset_dir):
+    def _get_num_samples(dataset_dir):
+        # Used only by dataset zoo
         return len(etads.load_dataset(dataset_dir))
 
 
diff --git a/fiftyone/utils/data/ingestors.py b/fiftyone/utils/data/ingestors.py
index cf1c357cfb8..74b938f8a9e 100644
--- a/fiftyone/utils/data/ingestors.py
+++ b/fiftyone/utils/data/ingestors.py
@@ -116,17 +116,15 @@ def __init__(
         sample_parser,
         image_format=None,
         max_samples=None,
-        **kwargs
     ):
-        for arg in kwargs:
-            logger.warning("Ignoring unsupported parameter '%s'", arg)
-
         UnlabeledImageDatasetImporter.__init__(
-            self, dataset_dir, max_samples=max_samples
+            self, dataset_dir=dataset_dir, max_samples=max_samples
         )
         ImageIngestor.__init__(self, dataset_dir, image_format=image_format)
+
         self.samples = samples
         self.sample_parser = sample_parser
+
         self._iter_samples = None
         self._num_samples = None
         self._num_imported = None
@@ -161,6 +159,7 @@ def __next__(self):
             image_metadata = None
 
         self._num_imported += 1
+
         return image_path, image_metadata
 
     @property
@@ -213,7 +212,6 @@ class LabeledImageDatasetIngestor(LabeledImageDatasetImporter, ImageIngestor):
         image_format (None): the image format to use when writing in-memory
             images to disk. By default, ``fiftyone.config.default_image_ext``
             is used
-        skip_unlabeled (False): whether to skip unlabeled images when importing
         max_samples (None): a maximum number of samples to import. By default,
             all samples are imported
     """
@@ -224,22 +222,16 @@ def __init__(
         samples,
         sample_parser,
         image_format=None,
-        skip_unlabeled=False,
         max_samples=None,
-        **kwargs
     ):
-        for arg in kwargs:
-            logger.warning("Ignoring unsupported parameter '%s'", arg)
-
         LabeledImageDatasetImporter.__init__(
-            self,
-            dataset_dir,
-            skip_unlabeled=skip_unlabeled,
-            max_samples=max_samples,
+            self, dataset_dir=dataset_dir, max_samples=max_samples
         )
         ImageIngestor.__init__(self, dataset_dir, image_format=image_format)
+
         self.samples = samples
         self.sample_parser = sample_parser
+
         self._iter_samples = None
         self._num_samples = None
         self._num_imported = None
@@ -262,16 +254,6 @@ def __next__(self):
         ):
             raise StopIteration
 
-        image_path, image_metadata, label = self._parse_next_sample()
-
-        if self.skip_unlabeled:
-            while label is None:
-                image_path, image_metadata, label = self._parse_next_sample()
-
-        self._num_imported += 1
-        return image_path, image_metadata, label
-
-    def _parse_next_sample(self):
         sample = next(self._iter_samples)
 
         self.sample_parser.with_sample(sample)
@@ -285,6 +267,8 @@ def _parse_next_sample(self):
 
         label = self.sample_parser.get_label()
 
+        self._num_imported += 1
+
         return image_path, image_metadata, label
 
     @property
@@ -359,18 +343,15 @@ class UnlabeledVideoDatasetIngestor(
             all samples are imported
     """
 
-    def __init__(
-        self, dataset_dir, samples, sample_parser, max_samples=None, **kwargs
-    ):
-        for arg in kwargs:
-            logger.warning("Ignoring unsupported parameter '%s'", arg)
-
+    def __init__(self, dataset_dir, samples, sample_parser, max_samples=None):
         UnlabeledVideoDatasetImporter.__init__(
-            self, dataset_dir, max_samples=max_samples
+            self, dataset_dir=dataset_dir, max_samples=max_samples
         )
         VideoIngestor.__init__(self, dataset_dir)
+
         self.samples = samples
         self.sample_parser = sample_parser
+
         self._iter_samples = None
         self._num_samples = None
         self._num_imported = None
@@ -405,6 +386,7 @@ def __next__(self):
             video_metadata = None
 
         self._num_imported += 1
+
         return video_path, video_metadata
 
     @property
@@ -444,32 +426,21 @@ class LabeledVideoDatasetIngestor(LabeledVideoDatasetImporter, VideoIngestor):
         sample_parser: an
             :class:`fiftyone.utils.data.parsers.LabeledVideoSampleParser` to
             use to parse the samples
-        skip_unlabeled (False): whether to skip unlabeled videos when importing
         max_samples (None): a maximum number of samples to import. By default,
             all samples are imported
     """
 
     def __init__(
-        self,
-        dataset_dir,
-        samples,
-        sample_parser,
-        skip_unlabeled=False,
-        max_samples=None,
-        **kwargs
+        self, dataset_dir, samples, sample_parser, max_samples=None,
     ):
-        for arg in kwargs:
-            logger.warning("Ignoring unsupported parameter '%s'", arg)
-
         LabeledVideoDatasetImporter.__init__(
-            self,
-            dataset_dir,
-            skip_unlabeled=skip_unlabeled,
-            max_samples=max_samples,
+            self, dataset_dir=dataset_dir, max_samples=max_samples
         )
         VideoIngestor.__init__(self, dataset_dir)
+
         self.samples = samples
         self.sample_parser = sample_parser
+
         self._iter_samples = None
         self._num_samples = None
         self._num_imported = None
@@ -492,21 +463,6 @@ def __next__(self):
         ):
             raise StopIteration
 
-        video_path, video_metadata, label, frames = self._parse_next_sample()
-
-        if self.skip_unlabeled:
-            while label is None and frames is None:
-                (
-                    video_path,
-                    video_metadata,
-                    label,
-                    frames,
-                ) = self._parse_next_sample()
-
-        self._num_imported += 1
-        return video_path, video_metadata, label, frames
-
-    def _parse_next_sample(self):
         sample = next(self._iter_samples)
 
         self.sample_parser.with_sample(sample)
@@ -521,6 +477,8 @@ def _parse_next_sample(self):
         label = self.sample_parser.get_label()
         frames = self.sample_parser.get_frame_labels()
 
+        self._num_imported += 1
+
         return video_path, video_metadata, label, frames
 
     @property
diff --git a/fiftyone/utils/geojson.py b/fiftyone/utils/geojson.py
index ab2d3bdbf2c..381bdf007c9 100644
--- a/fiftyone/utils/geojson.py
+++ b/fiftyone/utils/geojson.py
@@ -16,8 +16,7 @@
 import fiftyone.core.sample as fos
 import fiftyone.core.utils as fou
 import fiftyone.core.validation as fov
-from fiftyone.utils.data.exporters import GenericSampleDatasetExporter
-from fiftyone.utils.data.importers import GenericSampleDatasetImporter
+import fiftyone.utils.data as foud
 
 
 logger = logging.getLogger(__name__)
@@ -29,9 +28,9 @@ def load_location_data(
     """Loads geolocation data for the given samples from the given GeoJSON
     data.
 
-    The GeoJSON data must be a ``FeatureCollection`` whose features have either
-    their ``filename`` (name only) or ``filepath`` (absolute path) properties
-    populated, which are used to match the provided samples.
+    The GeoJSON data must be a ``FeatureCollection`` whose features have their
+    ``filename`` properties populated, which are used to match the provided
+    samples.
 
     Example GeoJSON data::
 
@@ -67,7 +66,7 @@ def load_location_data(
                         ]
                     },
                     "properties": {
-                        "filepath": "/path/to/b1c81faa-3df17267.jpg"
+                        "filename": "/path/to/b1c81faa-3df17267.jpg"
                     }
                 },
             ]
@@ -85,8 +84,7 @@ def load_location_data(
             :class:`fiftyone.core.labels.GeoLocation` field, that field is
             used, else a new "location" field is created
         skip_missing (True): whether to skip GeoJSON features with no
-            ``filename`` or ``filepath`` properties (True) or raise an error
-            (False)
+            ``filename`` properties (True) or raise an error (False)
     """
     if location_field is None:
         try:
@@ -115,14 +113,10 @@ def load_location_data(
     geometries = {}
     for feature in d.get("features", []):
         properties = feature["properties"]
-        if "filepath" in properties:
-            key = properties["filepath"]
-        elif "filename" in properties:
+        if "filename" in properties:
             key = properties["filename"]
         elif not skip_missing:
-            raise ValueError(
-                "Found feature with no `filename` or `filepath` property"
-            )
+            raise ValueError("Found feature with no `filename` property")
         else:
             continue
 
@@ -294,15 +288,42 @@ def extract_coordinates(d):
     return _parse_geometries(geometries)
 
 
-class GeoJSONImageDatasetImporter(GenericSampleDatasetImporter):
-    """Importer for image datasets whose labels and location data are stored in
-    GeoJSON format.
+class GeoJSONDatasetImporter(
+    foud.GenericSampleDatasetImporter, foud.ImportPathsMixin
+):
+    """Importer for image or video datasets whose location data and labels are
+    stored in GeoJSON format.
 
-    See :class:`fiftyone.types.dataset_types.GeoJSONImageDataset` for format
+    See :class:`fiftyone.types.dataset_types.GeoJSONDataset` for format
     details.
 
     Args:
-        dataset_dir: the dataset directory
+        dataset_dir (None): the dataset directory
+        data_path (None): an optional parameter that enables explicit control
+            over the location of the media. Can be any of the following:
+
+            -   a folder name like ``"data"`` or ``"data/"`` specifying a
+                subfolder of ``dataset_dir`` where the media files reside
+            -   an absolute directory path where the media files reside. In
+                this case, the ``dataset_dir`` has no effect on the location of
+                the data
+            -   a filename like ``"data.json"`` specifying the filename of the
+                JSON data manifest file in ``dataset_dir``
+            -   an absolute filepath specifying the location of the JSON data
+                manifest. In this case, ``dataset_dir`` has no effect on the
+                location of the data
+
+            If None, this parameter will default to whichever of ``data/`` or
+            ``data.json`` exists in the dataset directory
+        labels_path (None): an optional parameter that enables explicit control
+            over the location of the labels. Can be any of the following:
+
+            -   a filename like ``"labels.json"`` specifying the location of
+                the labels in ``dataset_dir``
+            -   an absolute filepath to the labels. In this case,
+                ``dataset_dir`` has no effect on the location of the labels
+
+            If None, the parameter will default to ``labels.json``
         location_field ("location"): the name of the field in which to store
             the location data
         multi_location (False): whether this GeoJSON may contain multiple
@@ -313,9 +334,11 @@ class GeoJSONImageDatasetImporter(GenericSampleDatasetImporter):
             functions that parse the property values (e.g., into the
             appropriate) :class:`fiftyone.core.labels.Label` types). By
             default, all properies are stored as primitive field values
-        skip_unlabeled (False): whether to skip unlabeled images when importing
-        skip_missing_media (False): whether to skip features with no
-            ``filename`` or ``filepath`` property
+        skip_missing_media (False): whether to skip (True) or raise an error
+            (False) when features with no ``filename`` property are encountered
+        include_all_data (False): whether to generate samples for all media in
+            the data directory (True) rather than only creating samples for
+            media with label entries (False)
         shuffle (False): whether to randomly shuffle the order in which the
             samples are imported
         seed (None): a random seed to use when shuffling
@@ -325,25 +348,44 @@ class GeoJSONImageDatasetImporter(GenericSampleDatasetImporter):
 
     def __init__(
         self,
-        dataset_dir,
+        dataset_dir=None,
+        data_path=None,
+        labels_path=None,
         location_field="location",
         multi_location=False,
         property_parsers=None,
-        skip_unlabeled=False,
         skip_missing_media=False,
+        include_all_data=False,
         shuffle=False,
         seed=None,
         max_samples=None,
     ):
+        data_path = self._parse_data_path(
+            dataset_dir=dataset_dir, data_path=data_path, default="data/",
+        )
+
+        labels_path = self._parse_labels_path(
+            dataset_dir=dataset_dir,
+            labels_path=labels_path,
+            default="labels.json",
+        )
+
         super().__init__(
-            dataset_dir, shuffle=shuffle, seed=seed, max_samples=max_samples
+            dataset_dir=dataset_dir,
+            shuffle=shuffle,
+            seed=seed,
+            max_samples=max_samples,
         )
+
+        self.data_path = data_path
+        self.labels_path = labels_path
         self.location_field = location_field
         self.multi_location = multi_location
         self.property_parsers = property_parsers
-        self.skip_unlabeled = skip_unlabeled
         self.skip_missing_media = skip_missing_media
-        self._data_dir = None
+        self.include_all_data = include_all_data
+
+        self._media_paths_map = None
         self._features_map = None
         self._filepaths = None
         self._iter_filepaths = None
@@ -390,49 +432,110 @@ def has_dataset_info(self):
         return False
 
     def setup(self):
-        self._data_dir = os.path.join(self.dataset_dir, "data")
-        json_path = os.path.join(self.dataset_dir, "labels.json")
-
-        geojson = etas.load_json(json_path)
-        _ensure_type(geojson, "FeatureCollection")
+        self._media_paths_map = self._load_data_map(self.data_path)
 
         features_map = {}
-        for feature in geojson.get("features", []):
-            properties = feature["properties"]
-            if "filepath" in properties:
-                filepath = properties.pop("filepath")
-            elif "filename" in properties:
-                filepath = os.path.join(
-                    self._data_dir, properties.pop("filename")
-                )
-            elif self.skip_missing_media:
-                continue
-            else:
-                raise ValueError(
-                    "Found feature with no ``filepath`` or ``filename`` "
-                    "property"
-                )
-
-            features_map[filepath] = feature
+
+        if self.labels_path is not None and os.path.isfile(self.labels_path):
+            geojson = etas.load_json(self.labels_path)
+            _ensure_type(geojson, "FeatureCollection")
+
+            for feature in geojson.get("features", []):
+                properties = feature["properties"]
+                if "filename" in properties:
+                    filename = properties.pop("filename")
+                    if os.path.isabs(filename):
+                        filepath = filename
+                    else:
+                        filepath = self._media_paths_map.get(filename, None)
+
+                    if filepath is None:
+                        if self.skip_missing_media:
+                            continue
+
+                        raise ValueError(
+                            "Could not locate media for feature with "
+                            "filename=%s" % filename
+                        )
+
+                elif self.skip_missing_media:
+                    continue
+                else:
+                    raise ValueError(
+                        "Found feature with no `filename` property"
+                    )
+
+                features_map[filepath] = feature
 
         filepaths = set(features_map.keys())
-        if not self.skip_unlabeled and os.path.isdir(self._data_dir):
-            filepaths.update(etau.list_files(self._data_dir, abs_paths=True))
+
+        if self.include_all_data:
+            filepaths.update(self._media_paths_map.values())
 
         self._features_map = features_map
-        self._filepaths = self._preprocess_list(list(filepaths))
+        self._filepaths = self._preprocess_list(sorted(filepaths))
         self._num_samples = len(self._filepaths)
 
 
-class GeoJSONImageDatasetExporter(GenericSampleDatasetExporter):
-    """Exporter for image datasets whose labels and location data are stored in
-    GeoJSON format.
+class GeoJSONDatasetExporter(
+    foud.GenericSampleDatasetExporter, foud.ExportPathsMixin
+):
+    """Exporter for image or video datasets whose location data and labels are
+    stored in GeoJSON format.
 
-    See :class:`fiftyone.types.dataset_types.GeoJSONImageDataset` for format
+    See :class:`fiftyone.types.dataset_types.GeoJSONDataset` for format
     details.
 
     Args:
-        export_dir: the directory to write the export
+        export_dir (None): the directory to write the export. This has no
+            effect if ``data_path`` and ``labels_path`` are absolute paths
+        data_path (None): an optional parameter that enables explicit control
+            over the location of the exported media. Can be any of the
+            following:
+
+            -   a folder name like ``"data"`` or ``"data/"`` specifying a
+                subfolder of ``export_dir`` in which to export the media
+            -   an absolute directory path in which to export the media. In
+                this case, the ``export_dir`` has no effect on the location of
+                the data
+            -   a JSON filename like ``"data.json"`` specifying the filename of
+                the manifest file in ``export_dir`` generated when
+                ``export_media`` is ``"manifest"``
+            -   an absolute filepath specifying the location to write the JSON
+                manifest file when ``export_media`` is ``"manifest"``. In this
+                case, ``export_dir`` has no effect on the location of the data
+
+            If None, the default value of this parameter will be chosen based
+            on the value of the ``export_media`` parameter
+        labels_path (None): an optional parameter that enables explicit control
+            over the location of the exported labels. Can be any of the
+            following:
+
+            -   a filename like ``"labels.json"`` specifying the location in
+                ``export_dir`` in which to export the labels
+            -   an absolute filepath to which to export the labels. In this
+                case, the ``export_dir`` has no effect on the location of the
+                labels
+
+            If None, the labels will be exported into ``export_dir`` using the
+            default filename
+        export_media (None): controls how to export the raw media. The
+            supported values are:
+
+            -   ``True``: copy all media files into the output directory
+            -   ``False``: don't export media
+            -   ``"move"``: move all media files into the output directory
+            -   ``"symlink"``: create symlinks to the media files in the output
+                directory
+            -   ``"manifest"``: create a ``data.json`` in the output directory
+                that maps UUIDs used in the labels files to the filepaths of
+                the source media, rather than exporting the actual media
+
+            If None, the default value of this parameter will be chosen based
+            on the value of the ``data_path`` parameter
+        image_format (None): the image format to use when writing in-memory
+            images to disk. By default, ``fiftyone.config.default_image_ext``
+            is used
         location_field (None): the name of the field containing the location
             data for each sample. Can be any of the following:
 
@@ -448,40 +551,57 @@ class GeoJSONImageDatasetExporter(GenericSampleDatasetExporter):
             the sample. By default, no properties are written
         omit_none_fields (True): whether to omit ``None``-valued Sample fields
             from the output properties
-        copy_media (True): whether to copy the source media into the export
-            directory (True) or simply embed the input filepaths in the output
-            JSON (False)
         pretty_print (False): whether to render the JSON in human readable
             format with newlines and indentations
     """
 
     def __init__(
         self,
-        export_dir,
+        export_dir=None,
+        data_path=None,
+        labels_path=None,
+        export_media=None,
+        image_format=None,
         location_field=None,
         property_makers=None,
         omit_none_fields=True,
-        copy_media=True,
         pretty_print=False,
     ):
-        super().__init__(export_dir)
+        data_path, export_media = self._parse_data_path(
+            export_dir=export_dir,
+            data_path=data_path,
+            export_media=export_media,
+            default="data/",
+        )
+
+        labels_path = self._parse_labels_path(
+            export_dir=export_dir,
+            labels_path=labels_path,
+            default="labels.json",
+        )
+
+        super().__init__(export_dir=export_dir)
+
+        self.data_path = data_path
+        self.labels_path = labels_path
+        self.export_media = export_media
+        self.image_format = image_format
         self.location_field = location_field
         self.property_makers = property_makers
         self.omit_none_fields = omit_none_fields
-        self.copy_media = copy_media
         self.pretty_print = pretty_print
-        self._data_dir = None
-        self._labels_path = None
+
         self._features = []
         self._location_field = None
-        self._filename_maker = None
+        self._media_exporter = None
 
     def setup(self):
-        self._data_dir = os.path.join(self.export_dir, "data")
-        self._labels_path = os.path.join(self.export_dir, "labels.json")
-        self._filename_maker = fou.UniqueFilenameMaker(
-            output_dir=self._data_dir
+        self._media_exporter = foud.ImageExporter(
+            self.export_media,
+            export_path=self.data_path,
+            default_ext=self.image_format,
         )
+        self._media_exporter.setup()
 
     def log_collection(self, sample_collection):
         if self.location_field is None:
@@ -496,14 +616,12 @@ def export_sample(self, sample):
                 if value is not None or not self.omit_none_fields:
                     properties[key] = fn(value)
 
-        if self.copy_media:
-            out_filepath = self._filename_maker.get_output_path(
-                sample.filepath
-            )
-            etau.copy_file(sample.filepath, out_filepath)
-            properties["filename"] = os.path.basename(out_filepath)
+        out_filepath, _ = self._media_exporter.export(sample.filepath)
+
+        if self.export_media == False:
+            properties["filename"] = sample.filepath
         else:
-            properties["filepath"] = sample.filepath
+            properties["filename"] = os.path.basename(out_filepath)
 
         location = sample[self.location_field]
         if location is not None:
@@ -518,8 +636,9 @@ def export_sample(self, sample):
     def close(self, *args):
         features = {"type": "FeatureCollection", "features": self._features}
         etas.write_json(
-            features, self._labels_path, pretty_print=self.pretty_print
+            features, self.labels_path, pretty_print=self.pretty_print
         )
+        self._media_exporter.close()
 
 
 def _to_geo_primitive(label):
diff --git a/fiftyone/utils/kitti.py b/fiftyone/utils/kitti.py
index abfc57efe2b..d806edb1565 100644
--- a/fiftyone/utils/kitti.py
+++ b/fiftyone/utils/kitti.py
@@ -14,10 +14,8 @@
 import eta.core.utils as etau
 import eta.core.web as etaw
 
-import fiftyone as fo
 import fiftyone.core.labels as fol
 import fiftyone.core.metadata as fom
-import fiftyone.core.utils as fou
 import fiftyone.utils.data as foud
 
 
@@ -59,15 +57,44 @@ def _parse_label(self, target, img=None):
         return load_kitti_detection_annotations(target, frame_size)
 
 
-class KITTIDetectionDatasetImporter(foud.LabeledImageDatasetImporter):
+class KITTIDetectionDatasetImporter(
+    foud.LabeledImageDatasetImporter, foud.ImportPathsMixin
+):
     """Importer for KITTI detection datasets stored on disk.
 
     See :class:`fiftyone.types.dataset_types.KITTIDetectionDataset` for format
     details.
 
     Args:
-        dataset_dir: the dataset directory
-        skip_unlabeled (False): whether to skip unlabeled images when importing
+        dataset_dir (None): the dataset directory
+        data_path (None): an optional parameter that enables explicit control
+            over the location of the media. Can be any of the following:
+
+            -   a folder name like ``"data"`` or ``"data/"`` specifying a
+                subfolder of ``dataset_dir`` where the media files reside
+            -   an absolute directory path where the media files reside. In
+                this case, the ``dataset_dir`` has no effect on the location of
+                the data
+            -   a filename like ``"data.json"`` specifying the filename of the
+                JSON data manifest file in ``dataset_dir``
+            -   an absolute filepath specifying the location of the JSON data
+                manifest. In this case, ``dataset_dir`` has no effect on the
+                location of the data
+
+            If None, this parameter will default to whichever of ``data/`` or
+            ``data.json`` exists in the dataset directory
+        labels_path (None): an optional parameter that enables explicit control
+            over the location of the labels. Can be any of the following:
+
+            -   a folder name like ``"labels"`` or ``"labels/"`` specifying the
+                location of the labels in ``dataset_dir``
+            -   an absolute folder path to the labels. In this case,
+                ``dataset_dir`` has no effect on the location of the labels
+
+            If None, the parameter will default to ``labels/``
+        include_all_data (False): whether to generate samples for all images in
+            the data directory (True) rather than only creating samples for
+            images with label entries (False)
         shuffle (False): whether to randomly shuffle the order in which the
             samples are imported
         seed (None): a random seed to use when shuffling
@@ -77,21 +104,37 @@ class KITTIDetectionDatasetImporter(foud.LabeledImageDatasetImporter):
 
     def __init__(
         self,
-        dataset_dir,
-        skip_unlabeled=False,
+        dataset_dir=None,
+        data_path=None,
+        labels_path=None,
+        include_all_data=False,
         shuffle=False,
         seed=None,
         max_samples=None,
     ):
+        data_path = self._parse_data_path(
+            dataset_dir=dataset_dir, data_path=data_path, default="data/",
+        )
+
+        labels_path = self._parse_labels_path(
+            dataset_dir=dataset_dir,
+            labels_path=labels_path,
+            default="labels/",
+        )
+
         super().__init__(
-            dataset_dir,
-            skip_unlabeled=skip_unlabeled,
+            dataset_dir=dataset_dir,
             shuffle=shuffle,
             seed=seed,
             max_samples=max_samples,
         )
-        self._uuids_to_image_paths = None
-        self._uuids_to_labels_paths = None
+
+        self.data_path = data_path
+        self.labels_path = labels_path
+        self.include_all_data = include_all_data
+
+        self._image_paths_map = None
+        self._labels_paths_map = None
         self._uuids = None
         self._iter_uuids = None
         self._num_samples = None
@@ -107,13 +150,13 @@ def __next__(self):
         uuid = next(self._iter_uuids)
 
         try:
-            image_path = self._uuids_to_image_paths[uuid]
+            image_path = self._image_paths_map[uuid]
         except KeyError:
             raise ValueError("No image found for sample '%s'" % uuid)
 
         image_metadata = fom.ImageMetadata.build_for(image_path)
 
-        labels_path = self._uuids_to_labels_paths.get(uuid, None)
+        labels_path = self._labels_paths_map.get(uuid, None)
         if labels_path:
             # Labeled image
             frame_size = (image_metadata.width, image_metadata.height)
@@ -139,66 +182,120 @@ def label_cls(self):
         return fol.Detections
 
     def setup(self):
-        to_uuid = lambda p: os.path.splitext(os.path.basename(p))[0]
+        self._image_paths_map = self._load_data_map(
+            self.data_path, ignore_exts=True
+        )
 
-        data_dir = os.path.join(self.dataset_dir, "data")
-        if os.path.isdir(data_dir):
-            self._uuids_to_image_paths = {
-                to_uuid(p): p
-                for p in etau.list_files(data_dir, abs_paths=True)
+        if self.labels_path is not None and os.path.isdir(self.labels_path):
+            self._labels_paths_map = {
+                os.path.splitext(os.path.basename(p))[0]: p
+                for p in etau.list_files(self.labels_path, abs_paths=True)
             }
         else:
-            self._uuids_to_image_paths = {}
+            self._labels_paths_map = {}
 
-        labels_dir = os.path.join(self.dataset_dir, "labels")
-        if os.path.isdir(labels_dir):
-            self._uuids_to_labels_paths = {
-                to_uuid(p): p
-                for p in etau.list_files(labels_dir, abs_paths=True)
-            }
-        else:
-            self._uuids_to_labels_paths = {}
+        uuids = set(self._labels_paths_map.keys())
 
-        if self.skip_unlabeled:
-            uuids = sorted(self._uuids_to_labels_paths.keys())
-        else:
-            uuids = sorted(self._uuids_to_image_paths.keys())
+        if self.include_all_data:
+            uuids.update(self._image_paths_map.keys())
 
-        self._uuids = self._preprocess_list(uuids)
+        self._uuids = self._preprocess_list(sorted(uuids))
         self._num_samples = len(self._uuids)
 
     @staticmethod
-    def get_num_samples(dataset_dir):
-        data_dir = os.path.join(dataset_dir, "data")
-        if not os.path.isdir(data_dir):
-            return 0
-
-        return len(etau.list_files(data_dir))
+    def _get_num_samples(dataset_dir):
+        # Used only by dataset zoo
+        return len(etau.list_files(os.path.join(dataset_dir, "data")))
 
 
-class KITTIDetectionDatasetExporter(foud.LabeledImageDatasetExporter):
+class KITTIDetectionDatasetExporter(
+    foud.LabeledImageDatasetExporter, foud.ExportPathsMixin
+):
     """Exporter that writes KITTI detection datasets to disk.
 
     See :class:`fiftyone.types.dataset_types.KITTIDetectionDataset` for format
     details.
 
     Args:
-        export_dir: the directory to write the export
+        export_dir (None): the directory to write the export. This has no
+            effect if ``data_path`` and ``labels_path`` are absolute paths
+        data_path (None): an optional parameter that enables explicit control
+            over the location of the exported media. Can be any of the
+            following:
+
+            -   a folder name like ``"data"`` or ``"data/"`` specifying a
+                subfolder of ``export_dir`` in which to export the media
+            -   an absolute directory path in which to export the media. In
+                this case, the ``export_dir`` has no effect on the location of
+                the data
+            -   a JSON filename like ``"data.json"`` specifying the filename of
+                the manifest file in ``export_dir`` generated when
+                ``export_media`` is ``"manifest"``
+            -   an absolute filepath specifying the location to write the JSON
+                manifest file when ``export_media`` is ``"manifest"``. In this
+                case, ``export_dir`` has no effect on the location of the data
+
+            If None, the default value of this parameter will be chosen based
+            on the value of the ``export_media`` parameter
+        labels_path (None): an optional parameter that enables explicit control
+            over the location of the exported labels. Can be any of the
+            following:
+
+            -   a folder name like ``"labels"`` or ``"labels/"`` specifying the
+                location in ``export_dir`` in which to export the labels
+            -   an absolute folder path to which to export the labels. In this
+                case, the ``export_dir`` has no effect on the location of the
+                labels
+
+            If None, the labels will be exported into ``export_dir`` using the
+            default folder name
+        export_media (None): controls how to export the raw media. The
+            supported values are:
+
+            -   ``True``: copy all media files into the output directory
+            -   ``False``: don't export media
+            -   ``"move"``: move all media files into the output directory
+            -   ``"symlink"``: create symlinks to the media files in the output
+                directory
+            -   ``"manifest"``: create a ``data.json`` in the output directory
+                that maps UUIDs used in the labels files to the filepaths of
+                the source media, rather than exporting the actual media
+
+            If None, the default value of this parameter will be chosen based
+            on the value of the ``data_path`` parameter
         image_format (None): the image format to use when writing in-memory
             images to disk. By default, ``fiftyone.config.default_image_ext``
             is used
     """
 
-    def __init__(self, export_dir, image_format=None):
-        if image_format is None:
-            image_format = fo.config.default_image_ext
+    def __init__(
+        self,
+        export_dir=None,
+        data_path=None,
+        labels_path=None,
+        export_media=None,
+        image_format=None,
+    ):
+        data_path, export_media = self._parse_data_path(
+            export_dir=export_dir,
+            data_path=data_path,
+            export_media=export_media,
+            default="data/",
+        )
+
+        labels_path = self._parse_labels_path(
+            export_dir=export_dir, labels_path=labels_path, default="labels/",
+        )
 
-        super().__init__(export_dir)
+        super().__init__(export_dir=export_dir)
+
+        self.data_path = data_path
+        self.labels_path = labels_path
+        self.export_media = export_media
         self.image_format = image_format
-        self._data_dir = None
-        self._labels_dir = None
-        self._filename_maker = None
+
         self._writer = None
+        self._media_exporter = None
 
     @property
     def requires_image_metadata(self):
@@ -209,34 +306,33 @@ def label_cls(self):
         return fol.Detections
 
     def setup(self):
-        self._data_dir = os.path.join(self.export_dir, "data")
-        self._labels_dir = os.path.join(self.export_dir, "labels")
-        self._filename_maker = fou.UniqueFilenameMaker(
-            output_dir=self._data_dir,
+        self._writer = KITTIAnnotationWriter()
+        self._media_exporter = foud.ImageExporter(
+            self.export_media,
+            export_path=self.data_path,
             default_ext=self.image_format,
             ignore_exts=True,
         )
-        self._writer = KITTIAnnotationWriter()
+        self._media_exporter.setup()
 
-        etau.ensure_dir(self._data_dir)
-        etau.ensure_dir(self._labels_dir)
+        etau.ensure_dir(self.labels_path)
 
     def export_sample(self, image_or_path, detections, metadata=None):
-        out_image_path = self._export_image_or_path(
-            image_or_path, self._filename_maker
-        )
+        out_image_path, uuid = self._media_exporter.export(image_or_path)
 
         if detections is None:
             return
 
-        name = os.path.splitext(os.path.basename(out_image_path))[0]
-        out_anno_path = os.path.join(self._labels_dir, name + ".txt")
+        out_anno_path = os.path.join(self.labels_path, uuid + ".txt")
 
         if metadata is None:
             metadata = fom.ImageMetadata.build_for(out_image_path)
 
         self._writer.write(detections, metadata, out_anno_path)
 
+    def close(self, *args):
+        self._media_exporter.close()
+
 
 class KITTIAnnotationWriter(object):
     """Class for writing annotations in KITTI detection format.
diff --git a/fiftyone/utils/openimages.py b/fiftyone/utils/openimages.py
index f3103f65d89..6b90921f0c9 100644
--- a/fiftyone/utils/openimages.py
+++ b/fiftyone/utils/openimages.py
@@ -57,7 +57,6 @@ class OpenImagesDatasetImporter(foud.LabeledImageDatasetImporter):
             to the dataset's ``info`` dictionary
         version ("v6"): the Open Images dataset format version being used.
             Supported values are ``("v6")``
-        skip_unlabeled (False): whether to skip unlabeled images when importing
         shuffle (False): whether to randomly shuffle the order in which the
             samples are imported
         seed (None): a random seed to use when shuffling
@@ -75,18 +74,17 @@ def __init__(
         image_ids_file=None,
         load_hierarchy=True,
         version="v6",
-        skip_unlabeled=False,
         shuffle=False,
         seed=None,
         max_samples=None,
     ):
         super().__init__(
-            dataset_dir,
-            skip_unlabeled=skip_unlabeled,
+            dataset_dir=dataset_dir,
             shuffle=shuffle,
             seed=seed,
             max_samples=max_samples,
         )
+
         self.label_types = label_types
         self.classes = classes
         self.attrs = attrs
@@ -94,6 +92,7 @@ def __init__(
         self.image_ids_file = image_ids_file
         self.load_hierarchy = load_hierarchy
         self.version = version
+
         self._data_dir = None
         self._info = None
         self._classes = None
@@ -125,6 +124,7 @@ def __next__(self):
             )
             if pos_labels is not None:
                 labels["positive_labels"] = pos_labels
+
             if neg_labels is not None:
                 labels["negative_labels"] = neg_labels
 
@@ -319,8 +319,10 @@ def setup(self):
 
         if attrs_map:
             self._info["attributes_map"] = attrs_map
+
         if all_attrs:
             self._info["attributes"] = all_attrs
+
         if seg_classes:
             self._info["segmentation_classes"] = seg_classes
 
@@ -370,7 +372,6 @@ class OpenImagesV6DatasetImporter(OpenImagesDatasetImporter):
             ``image_ids`` is provided, this parameter is ignored
         load_hierarchy (True): optionally load the classes hiearchy and add it
             to the info of the dataset
-        skip_unlabeled (False): whether to skip unlabeled images when importing
         shuffle (False): whether to randomly shuffle the order in which the
             samples are imported
         seed (None): a random seed to use when shuffling
@@ -387,7 +388,6 @@ def __init__(
         image_ids=None,
         image_ids_file=None,
         load_hierarchy=True,
-        skip_unlabeled=False,
         shuffle=False,
         seed=None,
         max_samples=None,
@@ -401,7 +401,6 @@ def __init__(
             image_ids_file=image_ids_file,
             load_hierarchy=load_hierarchy,
             version="v6",
-            skip_unlabeled=skip_unlabeled,
             shuffle=shuffle,
             seed=seed,
             max_samples=max_samples,
@@ -493,7 +492,7 @@ def download_open_images_split(
     else:
         downloaded_ids = []
         split_image_ids = _parse_image_ids(
-            image_ids, image_ids_file, dataset_dir, split=split, download=True,
+            image_ids, image_ids_file, dataset_dir, split=split, download=True
         )
 
     download = True
@@ -580,6 +579,7 @@ def _setup(
                 filtered_classes.append(c)
             except:
                 missing_classes.append(c)
+
         classes = filtered_classes
         if missing_classes:
             logger.warning(
@@ -666,6 +666,7 @@ def get_attributes(dataset_dir=None, version="v6"):
 
     if not dataset_dir:
         dataset_dir = os.path.join(fo.config.dataset_zoo_dir, "open-images")
+
     try:
         attrs_map = _get_attrs_map(dataset_dir, download=False)
     except FileNotFoundError:
@@ -694,11 +695,13 @@ def get_classes(dataset_dir=None, version="v6"):
 
     if not dataset_dir:
         dataset_dir = os.path.join(fo.config.dataset_zoo_dir, "open-images")
+
     try:
         classes_map = _get_classes_map(dataset_dir, download=False)
     except FileNotFoundError:
         with etau.TempDir() as tmp_dir:
-            classes_map = _get_classes_map(dataset_dir=tmp_dir, download=True,)
+            classes_map = _get_classes_map(dataset_dir=tmp_dir, download=True)
+
     return sorted(list(classes_map.values()))
 
 
@@ -721,11 +724,13 @@ def get_segmentation_classes(dataset_dir=None, version="v6"):
 
     if not dataset_dir:
         dataset_dir = os.path.join(fo.config.dataset_zoo_dir, "open-images")
+
     try:
         seg_classes = _get_seg_classes(dataset_dir, download=False)
     except FileNotFoundError:
         with etau.TempDir() as tmp_dir:
-            seg_classes = _get_seg_classes(dataset_dir=tmp_dir, download=True,)
+            seg_classes = _get_seg_classes(dataset_dir=tmp_dir, download=True)
+
     return seg_classes
 
 
@@ -771,7 +776,7 @@ def _get_classes_map(dataset_dir, download=True):
 def _get_seg_classes(dataset_dir, classes_map=None, download=True):
     if not classes_map:
         classes_map = _get_classes_map(
-            dataset_dir=dataset_dir, download=download,
+            dataset_dir=dataset_dir, download=download
         )
 
     annot_link = _ANNOTATION_DOWNLOAD_LINKS["general"]["segmentation_classes"]
@@ -800,7 +805,7 @@ def _get_hierarchy(dataset_dir, classes_map=None, download=True):
 
             if classes_map is None:
                 classes_map = _get_classes_map(
-                    dataset_dir=tmp_dir, download=download,
+                    dataset_dir=tmp_dir, download=download
                 )
 
             # Not included in standard classes
@@ -823,12 +828,14 @@ def _rename_subcategories(hierarchy, classes_map):
         subs = []
         for sub in hierarchy["Subcategory"]:
             subs.append(_rename_subcategories(sub, classes_map))
+
         hierarchy["Subcategory"] = subs
 
     if "Part" in hierarchy.keys():
         parts = []
         for part in hierarchy["Part"]:
             parts.append(_rename_subcategories(part, classes_map))
+
         hierarchy["Part"] = parts
 
     return hierarchy
@@ -846,6 +853,7 @@ def _parse_csv(filename, dataframe=False):
                 reader = csv.reader(csvfile, dialect)
             else:
                 reader = csv.reader(csvfile)
+
             data = [row for row in reader]
 
     return data
diff --git a/fiftyone/utils/patches.py b/fiftyone/utils/patches.py
index 88237560839..640295967ae 100644
--- a/fiftyone/utils/patches.py
+++ b/fiftyone/utils/patches.py
@@ -34,8 +34,8 @@ class ImagePatchesExtractor(object):
         force_square (False): whether to minimally manipulate the patch
             bounding boxes into squares prior to extraction
         alpha (None): an optional expansion/contraction to apply to the patches
-            before extracting them, in ``[-1, \infty)``. If provided, the
-            length and width of the box are expanded (or contracted, when
+            before extracting them, in ``[-1, inf)``. If provided, the length
+            and width of the box are expanded (or contracted, when
             ``alpha < 0``) by ``(100 * alpha)%``. For example, set
             ``alpha = 1.1`` to expand the boxes by 10%, and set ``alpha = 0.9``
             to contract the boxes by 10%
@@ -143,11 +143,10 @@ def extract_patch(img, detection, force_square=False, alpha=None):
         force_square (False): whether to minimally manipulate the patch
             bounding box into a square prior to extraction
         alpha (None): an optional expansion/contraction to apply to the patch
-            before extracting it, in ``[-1, \infty)``. If provided, the length
-            and width of the box are expanded (or contracted, when
-            ``alpha < 0``) by ``(100 * alpha)%``. For example, set
-            ``alpha = 1.1`` to expand the box by 10%, and set ``alpha = 0.9``
-            to contract the box by 10%
+            before extracting it, in ``[-1, inf)``. If provided, the length and
+            width of the box are expanded (or contracted, when ``alpha < 0``)
+            by ``(100 * alpha)%``. For example, set ``alpha = 1.1`` to expand
+            the box by 10%, and set ``alpha = 0.9`` to contract the box by 10%
 
     Returns:
         the image patch
diff --git a/fiftyone/utils/tf.py b/fiftyone/utils/tf.py
index 811d847639f..695c3a26c95 100644
--- a/fiftyone/utils/tf.py
+++ b/fiftyone/utils/tf.py
@@ -12,6 +12,7 @@
 
 import eta.core.utils as etau
 
+import fiftyone as fo
 import fiftyone.core.labels as fol
 import fiftyone.core.metadata as fom
 import fiftyone.core.utils as fou
@@ -453,26 +454,15 @@ class TFRecordsLabeledImageDatasetImporter(foud.LabeledImageDatasetImporter):
         images_dir: the directory in which the images will be written
         image_format (None): the image format to use to write the images to
             disk. By default, ``fiftyone.config.default_image_ext`` is used
-        skip_unlabeled (False): whether to skip unlabeled images when importing
         max_samples (None): a maximum number of samples to import. By default,
             all samples are imported
     """
 
     def __init__(
-        self,
-        dataset_dir,
-        images_dir,
-        image_format=None,
-        skip_unlabeled=False,
-        max_samples=None,
-        **kwargs
+        self, dataset_dir, images_dir, image_format=None, max_samples=None,
     ):
-        for arg in kwargs:
-            logger.warning("Ignoring unsupported parameter '%s'", arg)
+        super().__init__(dataset_dir=dataset_dir, max_samples=max_samples)
 
-        super().__init__(
-            dataset_dir, skip_unlabeled=skip_unlabeled, max_samples=max_samples
-        )
         self.images_dir = images_dir
         self.image_format = image_format
 
@@ -496,7 +486,7 @@ def has_image_metadata(self):
         return self._sample_parser.has_image_metadata
 
     def setup(self):
-        tf_records_patt = os.path.join(self.dataset_dir, "*")
+        tf_records_patt = os.path.join(self.dataset_dir, "*record*")
         tf_dataset = from_tf_records(tf_records_patt)
 
         self._dataset_ingestor = foud.LabeledImageDatasetIngestor(
@@ -504,7 +494,6 @@ def setup(self):
             tf_dataset,
             self._sample_parser,
             image_format=self.image_format,
-            skip_unlabeled=self.skip_unlabeled,
             max_samples=self.max_samples,
         )
         self._dataset_ingestor.setup()
@@ -538,7 +527,6 @@ class TFImageClassificationDatasetImporter(
         images_dir: the directory in which the images will be written
         image_format (None): the image format to use to write the images to
             disk. By default, ``fiftyone.config.default_image_ext`` is used
-        skip_unlabeled (False): whether to skip unlabeled images when importing
         max_samples (None): a maximum number of samples to import. By default,
             all samples are imported
     """
@@ -566,7 +554,6 @@ class TFObjectDetectionDatasetImporter(TFRecordsLabeledImageDatasetImporter):
         images_dir: the directory in which the images will be written
         image_format (None): the image format to use to write the images to
             disk. By default, ``fiftyone.config.default_image_ext`` is used
-        skip_unlabeled (False): whether to skip unlabeled images when importing
         max_samples (None): a maximum number of samples to import. By default,
             all samples are imported
     """
@@ -588,13 +575,22 @@ class TFRecordsDatasetExporter(foud.LabeledImageDatasetExporter):
         export_dir: the directory to write the export
         num_shards (None): an optional number of shards to split the records
             into (using a round robin strategy)
+        image_format (None): the image format to use when writing in-memory
+            images to disk. By default, ``fiftyone.config.default_image_ext``
+            is used
     """
 
-    def __init__(self, export_dir, num_shards=None):
-        super().__init__(export_dir)
+    def __init__(self, export_dir, num_shards=None, image_format=None):
+        if image_format is None:
+            image_format = fo.config.default_image_ext
+
+        super().__init__(export_dir=export_dir)
+
         self.num_shards = num_shards
-        self._filename_maker = None
+        self.image_format = image_format
+
         self._example_generator = None
+        self._filename_maker = None
         self._tf_records_writer = None
 
     @property
@@ -603,16 +599,19 @@ def requires_image_metadata(self):
 
     def setup(self):
         tf_records_path = os.path.join(self.export_dir, "tf.records")
-        self._filename_maker = fou.UniqueFilenameMaker()
+
         self._example_generator = self._make_example_generator()
+        self._filename_maker = fou.UniqueFilenameMaker(
+            default_ext=self.image_format
+        )
         self._tf_records_writer = TFRecordsWriter(
             tf_records_path, num_shards=self.num_shards
         )
         self._tf_records_writer.__enter__()
 
     def export_sample(self, image_or_path, label, metadata=None):
-        if self._is_image_path(image_or_path):
-            filename = self._filename_maker.get_output_path(image_or_path)
+        if etau.is_str(image_or_path):
+            filename = image_or_path
         else:
             filename = self._filename_maker.get_output_path()
 
@@ -644,6 +643,9 @@ class TFImageClassificationDatasetExporter(TFRecordsDatasetExporter):
         export_dir: the directory to write the export
         num_shards (None): an optional number of shards to split the records
             into (using a round robin strategy)
+        image_format (None): the image format to use when writing in-memory
+            images to disk. By default, ``fiftyone.config.default_image_ext``
+            is used
     """
 
     @property
@@ -663,14 +665,22 @@ class TFObjectDetectionDatasetExporter(TFRecordsDatasetExporter):
 
     Args:
         export_dir: the directory to write the export
-        classes (None): the list of possible class labels. If omitted, the
-            class list is dynamically generated as samples are processed
         num_shards (None): an optional number of shards to split the records
             into (using a round robin strategy)
+        image_format (None): the image format to use when writing in-memory
+            images to disk. By default, ``fiftyone.config.default_image_ext``
+            is used
+        classes (None): the list of possible class labels. If omitted, the
+            class list is dynamically generated as samples are processed
     """
 
-    def __init__(self, export_dir, classes=None, num_shards=None):
-        super().__init__(export_dir, num_shards=num_shards)
+    def __init__(
+        self, export_dir, num_shards=None, image_format=None, classes=None
+    ):
+        super().__init__(
+            export_dir, num_shards=num_shards, image_format=image_format
+        )
+
         self.classes = classes
 
     @property
diff --git a/fiftyone/utils/torch.py b/fiftyone/utils/torch.py
index d7c09ff2905..1e395b8a40f 100644
--- a/fiftyone/utils/torch.py
+++ b/fiftyone/utils/torch.py
@@ -8,6 +8,7 @@
 import logging
 import itertools
 import multiprocessing
+import sys
 
 import cv2
 import numpy as np
@@ -877,10 +878,11 @@ def recommend_num_workers():
     Returns:
         the recommended ``num_workers``
     """
-    if not torch.cuda.is_available() and torch.__version__.startswith("1.7"):
-        # There is a parallelism bug in torch==1.7 on CPU that prevents us from
+    if sys.platform == "darwin" and not torch.cuda.is_available():
+        # There is a parallelism bug on macOS with CPU that prevents us from
         # using `num_workers > 0`
         # https://stackoverflow.com/q/64772335
+        # https://github.com/pytorch/pytorch/issues/46409
         return 0
 
     try:
@@ -1068,8 +1070,8 @@ class TorchImagePatchesDataset(Dataset):
         force_square (False): whether to minimally manipulate the patch
             bounding boxes into squares prior to extraction
         alpha (None): an optional expansion/contraction to apply to the patches
-            before extracting them, in ``[-1, \infty)``. If provided, the
-            length and width of the box are expanded (or contracted, when
+            before extracting them, in ``[-1, inf)``. If provided, the length
+            and width of the box are expanded (or contracted, when
             ``alpha < 0``) by ``(100 * alpha)%``. For example, set
             ``alpha = 1.1`` to expand the boxes by 10%, and set ``alpha = 0.9``
             to contract the boxes by 10%
diff --git a/fiftyone/utils/voc.py b/fiftyone/utils/voc.py
index 0d69356a624..049a5e4b7bc 100644
--- a/fiftyone/utils/voc.py
+++ b/fiftyone/utils/voc.py
@@ -13,7 +13,6 @@
 
 import eta.core.utils as etau
 
-import fiftyone as fo
 import fiftyone.constants as foc
 import fiftyone.core.labels as fol
 import fiftyone.core.metadata as fom
@@ -90,15 +89,44 @@ def _parse_label(self, target, img=None):
         return annotation.to_detections()
 
 
-class VOCDetectionDatasetImporter(foud.LabeledImageDatasetImporter):
+class VOCDetectionDatasetImporter(
+    foud.LabeledImageDatasetImporter, foud.ImportPathsMixin
+):
     """Importer for VOC detection datasets stored on disk.
 
     See :class:`fiftyone.types.dataset_types.VOCDetectionDataset` for format
     details.
 
     Args:
-        dataset_dir: the dataset directory
-        skip_unlabeled (False): whether to skip unlabeled images when importing
+        dataset_dir (None): the dataset directory
+        data_path (None): an optional parameter that enables explicit control
+            over the location of the media. Can be any of the following:
+
+            -   a folder name like ``"data"`` or ``"data/"`` specifying a
+                subfolder of ``dataset_dir`` where the media files reside
+            -   an absolute directory path where the media files reside. In
+                this case, the ``dataset_dir`` has no effect on the location of
+                the data
+            -   a filename like ``"data.json"`` specifying the filename of the
+                JSON data manifest file in ``dataset_dir``
+            -   an absolute filepath specifying the location of the JSON data
+                manifest. In this case, ``dataset_dir`` has no effect on the
+                location of the data
+
+            If None, this parameter will default to whichever of ``data/`` or
+            ``data.json`` exists in the dataset directory
+        labels_path (None): an optional parameter that enables explicit control
+            over the location of the labels. Can be any of the following:
+
+            -   a folder name like ``"labels"`` or ``"labels/"`` specifying the
+                location of the labels in ``dataset_dir``
+            -   an absolute folder path to the labels. In this case,
+                ``dataset_dir`` has no effect on the location of the labels
+
+            If None, the parameter will default to ``labels/``
+        include_all_data (False): whether to generate samples for all images in
+            the data directory (True) rather than only creating samples for
+            images with label entries (False)
         shuffle (False): whether to randomly shuffle the order in which the
             samples are imported
         seed (None): a random seed to use when shuffling
@@ -108,21 +136,37 @@ class VOCDetectionDatasetImporter(foud.LabeledImageDatasetImporter):
 
     def __init__(
         self,
-        dataset_dir,
-        skip_unlabeled=False,
+        dataset_dir=None,
+        data_path=None,
+        labels_path=None,
+        include_all_data=False,
         shuffle=False,
         seed=None,
         max_samples=None,
     ):
+        data_path = self._parse_data_path(
+            dataset_dir=dataset_dir, data_path=data_path, default="data/",
+        )
+
+        labels_path = self._parse_labels_path(
+            dataset_dir=dataset_dir,
+            labels_path=labels_path,
+            default="labels/",
+        )
+
         super().__init__(
-            dataset_dir,
-            skip_unlabeled=skip_unlabeled,
+            dataset_dir=dataset_dir,
             shuffle=shuffle,
             seed=seed,
             max_samples=max_samples,
         )
-        self._uuids_to_image_paths = None
-        self._uuids_to_labels_paths = None
+
+        self.data_path = data_path
+        self.labels_path = labels_path
+        self.include_all_data = include_all_data
+
+        self._image_paths_map = None
+        self._labels_paths_map = None
         self._uuids = None
         self._iter_uuids = None
         self._num_samples = None
@@ -137,7 +181,7 @@ def __len__(self):
     def __next__(self):
         uuid = next(self._iter_uuids)
 
-        labels_path = self._uuids_to_labels_paths.get(uuid, None)
+        labels_path = self._labels_paths_map.get(uuid, None)
         if labels_path:
             # Labeled image
 
@@ -151,12 +195,14 @@ def __next__(self):
             else:
                 _uuid = None
 
-            if _uuid not in self._uuids_to_image_paths:
+            if _uuid not in self._image_paths_map:
                 _uuid = uuid
 
-            try:
-                image_path = self._uuids_to_image_paths[_uuid]
-            except KeyError:
+            if uuid in self._image_paths_map:
+                image_path = self._image_paths_map[uuid]
+            elif annotation.path and os.path.isfile(annotation.path):
+                image_path = annotation.path
+            else:
                 raise ValueError("No image found for sample '%s'" % _uuid)
 
             if annotation.metadata is None:
@@ -167,7 +213,7 @@ def __next__(self):
             detections = annotation.to_detections()
         else:
             # Unlabeled image
-            image_path = self._uuids_to_image_paths[uuid]
+            image_path = self._image_paths_map[uuid]
             image_metadata = fom.ImageMetadata.build_for(image_path)
             detections = None
 
@@ -186,63 +232,115 @@ def label_cls(self):
         return fol.Detections
 
     def setup(self):
-        to_uuid = lambda p: os.path.splitext(os.path.basename(p))[0]
+        self._image_paths_map = self._load_data_map(
+            self.data_path, ignore_exts=True
+        )
 
-        data_dir = os.path.join(self.dataset_dir, "data")
-        if os.path.isdir(data_dir):
-            self._uuids_to_image_paths = {
-                to_uuid(p): p
-                for p in etau.list_files(data_dir, abs_paths=True)
+        if self.labels_path is not None and os.path.isdir(self.labels_path):
+            self._labels_paths_map = {
+                os.path.splitext(os.path.basename(p))[0]: p
+                for p in etau.list_files(self.labels_path, abs_paths=True)
             }
         else:
-            self._uuids_to_image_paths = {}
+            self._labels_paths_map = {}
 
-        labels_dir = os.path.join(self.dataset_dir, "labels")
-        if os.path.isdir(labels_dir):
-            self._uuids_to_labels_paths = {
-                to_uuid(p): p
-                for p in etau.list_files(labels_dir, abs_paths=True)
-            }
-        else:
-            self._uuids_to_labels_paths = {}
+        uuids = set(self._labels_paths_map.keys())
 
-        if self.skip_unlabeled:
-            uuids = sorted(self._uuids_to_labels_paths.keys())
-        else:
-            # Allow uuid to missing from `_uuids_to_image_paths` since we will
-            # try to use filepath from labels, if present
-            uuids = sorted(
-                set(self._uuids_to_image_paths.keys())
-                | set(self._uuids_to_labels_paths.keys())
-            )
+        if self.include_all_data:
+            uuids.update(self._image_paths_map.keys())
 
-        self._uuids = self._preprocess_list(uuids)
+        self._uuids = self._preprocess_list(sorted(uuids))
         self._num_samples = len(self._uuids)
 
 
-class VOCDetectionDatasetExporter(foud.LabeledImageDatasetExporter):
+class VOCDetectionDatasetExporter(
+    foud.LabeledImageDatasetExporter, foud.ExportPathsMixin
+):
     """Exporter that writes VOC detection datasets to disk.
 
     See :class:`fiftyone.types.dataset_types.VOCDetectionDataset` for format
     details.
 
     Args:
-        export_dir: the directory to write the export
+        export_dir (None): the directory to write the export. This has no
+            effect if ``data_path`` and ``labels_path`` are absolute paths
+        data_path (None): an optional parameter that enables explicit control
+            over the location of the exported media. Can be any of the
+            following:
+
+            -   a folder name like ``"data"`` or ``"data/"`` specifying a
+                subfolder of ``export_dir`` in which to export the media
+            -   an absolute directory path in which to export the media. In
+                this case, the ``export_dir`` has no effect on the location of
+                the data
+            -   a JSON filename like ``"data.json"`` specifying the filename of
+                the manifest file in ``export_dir`` generated when
+                ``export_media`` is ``"manifest"``
+            -   an absolute filepath specifying the location to write the JSON
+                manifest file when ``export_media`` is ``"manifest"``. In this
+                case, ``export_dir`` has no effect on the location of the data
+
+            If None, the default value of this parameter will be chosen based
+            on the value of the ``export_media`` parameter
+        labels_path (None): an optional parameter that enables explicit control
+            over the location of the exported labels. Can be any of the
+            following:
+
+            -   a folder name like ``"labels"`` or ``"labels/"`` specifying the
+                location in ``export_dir`` in which to export the labels
+            -   an absolute folder path to which to export the labels. In this
+                case, the ``export_dir`` has no effect on the location of the
+                labels
+
+            If None, the labels will be exported into ``export_dir`` using the
+            default folder name
+        export_media (None): controls how to export the raw media. The
+            supported values are:
+
+            -   ``True``: copy all media files into the output directory
+            -   ``False``: don't export media
+            -   ``"move"``: move all media files into the output directory
+            -   ``"symlink"``: create symlinks to the media files in the output
+                directory
+            -   ``"manifest"``: create a ``data.json`` in the output directory
+                that maps UUIDs used in the labels files to the filepaths of
+                the source media, rather than exporting the actual media
+
+            If None, the default value of this parameter will be chosen based
+            on the value of the ``data_path`` parameter
         image_format (None): the image format to use when writing in-memory
             images to disk. By default, ``fiftyone.config.default_image_ext``
             is used
     """
 
-    def __init__(self, export_dir, image_format=None):
-        if image_format is None:
-            image_format = fo.config.default_image_ext
+    def __init__(
+        self,
+        export_dir=None,
+        data_path=None,
+        labels_path=None,
+        export_media=None,
+        image_format=None,
+    ):
+        data_path, export_media = self._parse_data_path(
+            export_dir=export_dir,
+            data_path=data_path,
+            export_media=export_media,
+            default="data/",
+        )
+
+        labels_path = self._parse_labels_path(
+            export_dir=export_dir, labels_path=labels_path, default="labels/",
+        )
 
-        super().__init__(export_dir)
+        super().__init__(export_dir=export_dir)
+
+        self.data_path = data_path
+        self.labels_path = labels_path
+        self.export_media = export_media
         self.image_format = image_format
-        self._data_dir = None
-        self._labels_dir = None
-        self._filename_maker = None
+
         self._writer = None
+        self._media_exporter = None
 
     @property
     def requires_image_metadata(self):
@@ -253,37 +351,36 @@ def label_cls(self):
         return fol.Detections
 
     def setup(self):
-        self._data_dir = os.path.join(self.export_dir, "data")
-        self._labels_dir = os.path.join(self.export_dir, "labels")
-        self._filename_maker = fou.UniqueFilenameMaker(
-            output_dir=self._data_dir,
+        self._writer = VOCAnnotationWriter()
+        self._media_exporter = foud.ImageExporter(
+            self.export_media,
+            export_path=self.data_path,
             default_ext=self.image_format,
             ignore_exts=True,
         )
-        self._writer = VOCAnnotationWriter()
+        self._media_exporter.setup()
 
-        etau.ensure_dir(self._data_dir)
-        etau.ensure_dir(self._labels_dir)
+        etau.ensure_dir(self.labels_path)
 
     def export_sample(self, image_or_path, detections, metadata=None):
-        out_image_path = self._export_image_or_path(
-            image_or_path, self._filename_maker
-        )
+        if metadata is None and detections is not None:
+            metadata = fom.ImageMetadata.build_for(image_or_path)
+
+        out_image_path, uuid = self._media_exporter.export(image_or_path)
 
         if detections is None:
             return
 
-        name = os.path.splitext(os.path.basename(out_image_path))[0]
-        out_anno_path = os.path.join(self._labels_dir, name + ".xml")
-
-        if metadata is None:
-            metadata = fom.ImageMetadata.build_for(out_image_path)
+        out_anno_path = os.path.join(self.labels_path, uuid + ".xml")
 
         annotation = VOCAnnotation.from_labeled_image(
             out_image_path, metadata, detections
         )
         self._writer.write(annotation, out_anno_path)
 
+    def close(self, *args):
+        self._media_exporter.close()
+
 
 class VOCAnnotation(object):
     """Class representing a VOC annotations file.
@@ -391,10 +488,14 @@ def from_dict(cls, d):
 
         if "size" in annotation:
             size = annotation["size"]
+            width = size.get("width", None)
+            height = size.get("height", None)
+            depth = size.get("depth", None)
+
             metadata = fom.ImageMetadata(
-                width=int(size["width"]),
-                height=int(size["height"]),
-                num_channels=int(size["depth"]),
+                width=int(width) if width else None,
+                height=int(height) if height else None,
+                num_channels=int(depth) if depth else None,
             )
         else:
             metadata = None
@@ -642,9 +743,9 @@ def write(self, annotation, xml_path):
                 "path": annotation.path,
                 "filename": annotation.filename,
                 "folder": annotation.folder,
-                "width": metadata.width,
-                "height": metadata.height,
-                "depth": metadata.num_channels,
+                "width": metadata.width or "",
+                "height": metadata.height or "",
+                "depth": metadata.num_channels or "",
                 "database": None,
                 "segmented": annotation.segmented,
                 "objects": annotation.objects,
diff --git a/fiftyone/utils/yolo.py b/fiftyone/utils/yolo.py
index 6e28eebc0e2..5e64992b33f 100644
--- a/fiftyone/utils/yolo.py
+++ b/fiftyone/utils/yolo.py
@@ -1,24 +1,26 @@
 """
-Utilities for working with datasets in
-`YOLO format <https://github.com/AlexeyAB/darknet>`_.
+Utilities for working with datasets in YOLO format.
 
 | Copyright 2017-2021, Voxel51, Inc.
 | `voxel51.com <https://voxel51.com/>`_
 |
 """
+import logging
 import os
 
+import yaml
+
 import eta.core.utils as etau
 
-import fiftyone as fo
 import fiftyone.core.labels as fol
-import fiftyone.core.utils as fou
 import fiftyone.utils.data as foud
 
 
+logger = logging.getLogger(__name__)
+
+
 class YOLOSampleParser(foud.ImageDetectionSampleParser):
-    """Parser for samples in
-    `YOLO format <https://github.com/AlexeyAB/darknet>`_.
+    """Parser for samples whose labels are in YOLO-style format.
 
     This implementation supports samples that are
     ``(image_or_path, anno_txt_path)`` tuples, where:
@@ -29,7 +31,8 @@ class YOLOSampleParser(foud.ImageDetectionSampleParser):
         - ``anno_txt_path`` is the path to a YOLO labels TXT file on disk. Or,
           for unlabeled images, ``anno_txt_path`` can be ``None``.
 
-    See :class:`fiftyone.types.dataset_types.YOLODataset` for format details.
+    See :class:`fiftyone.types.dataset_types.YOLOv4Dataset` or
+    :class:`fiftyone.types.dataset_types.YOLOv5Dataset` for format details.
 
     Args:
         classes (None): a list of class label strings. If provided, it is
@@ -54,14 +57,46 @@ def _parse_label(self, target, img=None):
         return load_yolo_annotations(target, self.classes)
 
 
-class YOLODatasetImporter(foud.LabeledImageDatasetImporter):
-    """Importer for YOLO datasets stored on disk.
+class YOLOv4DatasetImporter(
+    foud.LabeledImageDatasetImporter, foud.ImportPathsMixin
+):
+    """Importer for YOLOv4 datasets stored on disk.
 
-    See :class:`fiftyone.types.dataset_types.YOLODataset` for format details.
+    See :class:`fiftyone.types.dataset_types.YOLOv4Dataset` for format details.
 
     Args:
-        dataset_dir: the dataset directory
-        skip_unlabeled (False): whether to skip unlabeled images when importing
+        dataset_dir (None): the dataset directory
+        data_path (None): an optional parameter that enables explicit control
+            over the location of the media. Can be any of the following:
+
+            -   a folder name like ``"data"`` or ``"data/"`` specifying a
+                subfolder of ``dataset_dir`` where the media files reside
+            -   an absolute directory path where the media files reside. In
+                this case, the ``dataset_dir`` has no effect on the location of
+                the data
+
+            If None, this parameter will default to whichever of ``data/`` or
+            ``data.json`` exists in the dataset directory
+        images_path (None): an optional parameter that enables explicit
+            control over the location of the image listing file. Can be any of
+            the following:
+
+            -   a filename like ``"images.txt"`` specifying the location of the
+                image listing file labels in ``dataset_dir``
+            -   an absolute filepath to the image listing file. In this case,
+                ``dataset_dir`` has no effect on the location of the file
+
+            If None, the parameter will default to ``images.txt``
+        objects_path (None): an optional parameter that enables explicit
+            control over the location of the object names file. Can be any of
+            the following:
+
+            -   a filename like ``"obj.names"`` specifying the location of the
+                object names file labels in ``dataset_dir``
+            -   an absolute filepath to the object names file. In this case,
+                ``dataset_dir`` has no effect on the location of the file
+
+            If None, the parameter will default to ``obj.names``
         shuffle (False): whether to randomly shuffle the order in which the
             samples are imported
         seed (None): a random seed to use when shuffling
@@ -71,23 +106,45 @@ class YOLODatasetImporter(foud.LabeledImageDatasetImporter):
 
     def __init__(
         self,
-        dataset_dir,
-        skip_unlabeled=False,
+        dataset_dir=None,
+        data_path=None,
+        images_path=None,
+        objects_path=None,
         shuffle=False,
         seed=None,
         max_samples=None,
     ):
+        data_path = self._parse_data_path(
+            dataset_dir=dataset_dir, data_path=data_path, default="data/",
+        )
+
+        images_path = self._parse_labels_path(
+            dataset_dir=dataset_dir,
+            labels_path=images_path,
+            default="images.txt",
+        )
+
+        objects_path = self._parse_labels_path(
+            dataset_dir=dataset_dir,
+            labels_path=objects_path,
+            default="obj.names",
+        )
+
         super().__init__(
-            dataset_dir,
-            skip_unlabeled=skip_unlabeled,
+            dataset_dir=dataset_dir,
             shuffle=shuffle,
             seed=seed,
             max_samples=max_samples,
         )
+
+        self.data_path = data_path
+        self.images_path = images_path
+        self.objects_path = objects_path
+
         self._classes = None
         self._info = None
-        self._uuids_to_image_paths = None
-        self._uuids_to_labels_paths = None
+        self._image_paths_map = None
+        self._labels_paths_map = None
         self._uuids = None
         self._iter_uuids = None
         self._num_samples = None
@@ -103,11 +160,11 @@ def __next__(self):
         uuid = next(self._iter_uuids)
 
         try:
-            image_path = self._uuids_to_image_paths[uuid]
+            image_path = self._image_paths_map[uuid]
         except KeyError:
             raise ValueError("No image found for sample '%s'" % uuid)
 
-        labels_path = self._uuids_to_labels_paths.get(uuid, None)
+        labels_path = self._labels_paths_map.get(uuid, None)
         if labels_path:
             # Labeled image
             detections = load_yolo_annotations(labels_path, self._classes)
@@ -130,9 +187,43 @@ def label_cls(self):
         return fol.Detections
 
     def setup(self):
-        classes_path = os.path.join(self.dataset_dir, "obj.names")
-        if os.path.exists(classes_path):
-            classes = _read_file_lines(classes_path)
+        if self.images_path is not None and os.path.exists(self.images_path):
+            root_dir = os.path.dirname(self.images_path)
+
+            image_paths_map = {}
+            for path in _read_file_lines(self.images_path):
+                uuid = os.path.splitext(os.path.basename(path))[0]
+                if os.path.isabs(path):
+                    image_paths_map[uuid] = path
+                else:
+                    image_paths_map[uuid] = os.path.join(root_dir, path)
+        else:
+            logger.warning(
+                "Images file '%s' not found. Listing data directory '%s' "
+                "instead",
+                self.images_path,
+                self.data_path,
+            )
+
+            image_paths_map = {
+                os.path.splitext(os.path.basename(path))[0]: path
+                for path in etau.list_files(self.data_path, abs_paths=True)
+                if not path.endswith(".txt")
+            }
+
+        uuids = sorted(image_paths_map.keys())
+
+        labels_paths_map = {}
+        for uuid, image_path in image_paths_map.items():
+            labels_path = os.path.splitext(image_path)[0] + ".txt"
+            if os.path.exists(labels_path):
+                labels_paths_map[uuid] = labels_path
+
+        self._image_paths_map = image_paths_map
+        self._labels_paths_map = labels_paths_map
+
+        if self.objects_path is not None and os.path.exists(self.objects_path):
+            classes = _read_file_lines(self.objects_path)
         else:
             classes = None
 
@@ -140,49 +231,226 @@ def setup(self):
         if classes is not None:
             info["classes"] = classes
 
-        images_path = os.path.join(self.dataset_dir, "images.txt")
-        if os.path.exists(images_path):
-            images = _read_file_lines(images_path)
+        self._classes = classes
+        self._info = info
+        self._uuids = self._preprocess_list(uuids)
+        self._num_samples = len(self._uuids)
+
+    def get_dataset_info(self):
+        return self._info
+
+
+class YOLOv5DatasetImporter(
+    foud.LabeledImageDatasetImporter, foud.ImportPathsMixin
+):
+    """Importer for YOLOv5 datasets stored on disk.
+
+    See :class:`fiftyone.types.dataset_types.YOLOv5Dataset` for format details.
+
+    Args:
+        dataset_dir (None): the dataset directory
+        yaml_path (None): an optional parameter that enables explicit control
+            over the location of the dataset YAML file. Can be any of the
+            following:
+
+            -   a filename like ``"dataset.yaml"`` specifying the name of the
+                YAML file in ``dataset_dir``
+            -   an absolute path to the YAML file. In this case,
+                ``dataset_dir`` has no effect
+
+            If None, the parameter will default to ``dataset.yaml``
+        split ("val"): the split to load. Typical values are
+            ``("train", "val")``
+        shuffle (False): whether to randomly shuffle the order in which the
+            samples are imported
+        seed (None): a random seed to use when shuffling
+        max_samples (None): a maximum number of samples to import. By default,
+            all samples are imported
+    """
+
+    def __init__(
+        self,
+        dataset_dir=None,
+        yaml_path=None,
+        split="val",
+        shuffle=False,
+        seed=None,
+        max_samples=None,
+    ):
+        yaml_path = self._parse_labels_path(
+            dataset_dir=dataset_dir,
+            labels_path=yaml_path,
+            default="dataset.yaml",
+        )
+
+        super().__init__(
+            dataset_dir=dataset_dir,
+            shuffle=shuffle,
+            seed=seed,
+            max_samples=max_samples,
+        )
+
+        self.yaml_path = yaml_path
+        self.split = split
+
+        self._classes = None
+        self._info = None
+        self._image_paths_map = None
+        self._labels_paths_map = None
+        self._uuids = None
+        self._iter_uuids = None
+        self._num_samples = None
+
+    def __iter__(self):
+        self._iter_uuids = iter(self._uuids)
+        return self
+
+    def __len__(self):
+        return self._num_samples
+
+    def __next__(self):
+        uuid = next(self._iter_uuids)
+
+        try:
+            image_path = self._image_paths_map[uuid]
+        except KeyError:
+            raise ValueError("No image found for sample '%s'" % uuid)
+
+        labels_path = self._labels_paths_map.get(uuid, None)
+        if labels_path:
+            # Labeled image
+            detections = load_yolo_annotations(labels_path, self._classes)
         else:
-            images = []
+            # Unlabeled image
+            detections = None
+
+        return image_path, None, detections
+
+    @property
+    def has_dataset_info(self):
+        return True
+
+    @property
+    def has_image_metadata(self):
+        return False
 
-        uuids = []
-        uuids_to_image_paths = {}
-        uuids_to_labels_paths = {}
-        for image in images:
-            uuid = os.path.splitext(os.path.basename(image))[0]
-            uuids.append(uuid)
+    @property
+    def label_cls(self):
+        return fol.Detections
 
-            uuids_to_image_paths[uuid] = os.path.join(self.dataset_dir, image)
+    def setup(self):
+        d = _read_yaml_file(self.yaml_path)
 
-            labels_path = os.path.join(
-                self.dataset_dir, os.path.splitext(image)[0] + ".txt"
+        if self.split not in d:
+            raise ValueError(
+                "Dataset YAML '%s' does not contain split '%s'"
+                % (self.yaml_path, self.split)
             )
 
+        data = d[self.split]
+        classes = d.get("names", None)
+
+        if data.endswith(".txt"):
+            txt_path = _parse_yolo_v5_data_path(data, self.yaml_path)
+            image_paths = _read_file_lines(txt_path)
+        else:
+            if etau.is_str(data):
+                data_dirs = [data]
+            else:
+                data_dirs = data
+
+            image_paths = []
+            for data_dir in data_dirs:
+                data_dir = _parse_yolo_v5_data_path(data_dir, self.yaml_path)
+                image_paths.extend(etau.list_files(data_dir, abs_paths=True))
+
+        image_paths_map = {
+            os.path.splitext(os.path.basename(p))[0]: p for p in image_paths
+        }
+
+        uuids = sorted(image_paths_map.keys())
+
+        labels_paths_map = {}
+        for uuid, image_path in image_paths_map.items():
+            labels_path = _get_yolo_v5_labels_path(image_path)
             if os.path.exists(labels_path):
-                uuids_to_labels_paths[uuid] = labels_path
+                labels_paths_map[uuid] = labels_path
+
+        self._image_paths_map = image_paths_map
+        self._labels_paths_map = labels_paths_map
 
-        if self.skip_unlabeled:
-            uuids = list(uuids_to_labels_paths.keys())
+        info = {}
+        if classes is not None:
+            info["classes"] = classes
 
         self._classes = classes
         self._info = info
         self._uuids = self._preprocess_list(uuids)
-        self._uuids_to_image_paths = uuids_to_image_paths
-        self._uuids_to_labels_paths = uuids_to_labels_paths
         self._num_samples = len(self._uuids)
 
     def get_dataset_info(self):
         return self._info
 
 
-class YOLODatasetExporter(foud.LabeledImageDatasetExporter):
-    """Exporter that writes YOLO datasets to disk.
+class YOLOv4DatasetExporter(
+    foud.LabeledImageDatasetExporter, foud.ExportPathsMixin
+):
+    """Exporter that writes YOLOv4 datasets to disk.
 
-    See :class:`fiftyone.types.dataset_types.YOLODataset` for format details.
+    See :class:`fiftyone.types.dataset_types.YOLOv4Dataset` for format details.
 
     Args:
-        export_dir: the directory to write the export
+        export_dir (None): the directory to write the export. This has no
+            effect if ``data_path``, ``objects_path``, and ``images_path`` are
+            absolute paths
+        data_path (None): an optional parameter that enables explicit control
+            over the location of the exported data and labels. Can be any of
+            the following:
+
+            -   a folder name like ``"data"`` or ``"data/"`` specifying a
+                subfolder of ``export_dir`` in which to export the data and
+                labels
+            -   an absolute directory path in which to export the data and
+                labels. In this case, the ``export_dir`` has no effect on the
+                location of the data
+
+            If None, the data will be written into ``export_dir`` using the
+            default folder name
+        objects_path (None): an optional parameter that enables explicit
+            control over the location of the object names file. Can be any of
+            the following:
+
+            -   a filename like ``"obj.names"`` specifying the location in
+                ``export_dir`` in which to export the object names
+            -   an absolute filepath to which to export the object names. In
+                this case, the ``export_dir`` has no effect on the location of
+                the object names
+
+            If None, the object names will be written into ``export_dir``
+            using the default filename
+        images_path (None): an optional parameter that enables explicit control
+            over the location of the image listing file. Can be any of the
+            following:
+
+            -   a filename like ``"images.txt"`` specifying the location in
+                ``export_dir`` in which to export the image listing
+            -   an absolute filepath to which to export the image listing. In
+                this case, the ``export_dir`` has no effect on the location of
+                the image listing
+
+            If None, the image listing will be written into ``export_dir``
+            using the default filename
+        export_media (None): controls how to export the raw media. The
+            supported values are:
+
+            -   ``True``: copy all media files into the output directory
+            -   ``False``: don't export media
+            -   ``"move"``: move all media files into the output directory
+            -   ``"symlink"``: create symlinks to the media files in the output
+                directory
+
+            If None, the default value of this parameter will be chosen based
+            on the value of the ``data_path`` parameter
         classes (None): the list of possible class labels. If not provided,
             this list will be extracted when :meth:`log_collection` is called,
             if possible
@@ -191,23 +459,51 @@ class YOLODatasetExporter(foud.LabeledImageDatasetExporter):
             is used
     """
 
-    def __init__(self, export_dir, classes=None, image_format=None):
-        if image_format is None:
-            image_format = fo.config.default_image_ext
+    def __init__(
+        self,
+        export_dir=None,
+        data_path=None,
+        objects_path=None,
+        images_path=None,
+        export_media=None,
+        classes=None,
+        image_format=None,
+    ):
+        data_path, export_media = self._parse_data_path(
+            export_dir=export_dir,
+            data_path=data_path,
+            export_media=export_media,
+            default="data/",
+        )
+
+        objects_path = self._parse_labels_path(
+            export_dir=export_dir,
+            labels_path=objects_path,
+            default="obj.names",
+        )
+
+        images_path = self._parse_labels_path(
+            export_dir=export_dir,
+            labels_path=images_path,
+            default="images.txt",
+        )
+
+        super().__init__(export_dir=export_dir)
 
-        super().__init__(export_dir)
+        self.data_path = data_path
+        self.objects_path = objects_path
+        self.images_path = images_path
+        self.export_media = export_media
         self.classes = classes
         self.image_format = image_format
 
         self._classes = None
         self._dynamic_classes = classes is None
         self._labels_map_rev = None
-        self._obj_names_path = None
-        self._images_path = None
-        self._data_dir = None
+        self._rel_dir = None
         self._images = None
-        self._filename_maker = None
         self._writer = None
+        self._media_exporter = None
 
     @property
     def requires_image_metadata(self):
@@ -218,24 +514,218 @@ def label_cls(self):
         return fol.Detections
 
     def setup(self):
-        self._obj_names_path = os.path.join(self.export_dir, "obj.names")
-        self._images_path = os.path.join(self.export_dir, "images.txt")
-        self._data_dir = os.path.join(self.export_dir, "data")
+        if self.export_dir is None:
+            rel_dir = self.data_path
+        else:
+            rel_dir = self.export_dir
+
+        self._rel_dir = rel_dir
 
         self._classes = {}
         self._labels_map_rev = {}
         self._images = []
+        self._writer = YOLOAnnotationWriter()
+
+        self._parse_classes()
 
-        self._filename_maker = fou.UniqueFilenameMaker(
-            output_dir=self._data_dir,
+        self._media_exporter = foud.ImageExporter(
+            self.export_media,
+            export_path=self.data_path,
+            supported_modes=(True, False, "move", "symlink"),
             default_ext=self.image_format,
             ignore_exts=True,
         )
+        self._media_exporter.setup()
+
+    def log_collection(self, sample_collection):
+        if self.classes is None:
+            if sample_collection.default_classes:
+                self.classes = sample_collection.default_classes
+                self._parse_classes()
+                self._dynamic_classes = False
+            elif sample_collection.classes:
+                self.classes = next(iter(sample_collection.classes.values()))
+                self._parse_classes()
+                self._dynamic_classes = False
+            elif "classes" in sample_collection.info:
+                self.classes = sample_collection.info["classes"]
+                self._parse_classes()
+                self._dynamic_classes = False
+
+    def export_sample(self, image_or_path, detections, metadata=None):
+        out_image_path, _ = self._media_exporter.export(image_or_path)
+
+        self._images.append(os.path.relpath(out_image_path, self._rel_dir))
+
+        if detections is None:
+            return
+
+        out_labels_path = os.path.splitext(out_image_path)[0] + ".txt"
+
+        self._writer.write(
+            detections,
+            out_labels_path,
+            self._labels_map_rev,
+            dynamic_classes=self._dynamic_classes,
+        )
+
+    def close(self, *args):
+        if self._dynamic_classes:
+            classes = _to_classes(self._labels_map_rev)
+        else:
+            classes = self.classes
+
+        _write_file_lines(classes, self.objects_path)
+        _write_file_lines(self._images, self.images_path)
+
+        self._media_exporter.close()
+
+    def _parse_classes(self):
+        if self.classes is not None:
+            self._labels_map_rev = _to_labels_map_rev(self.classes)
+
+
+class YOLOv5DatasetExporter(
+    foud.LabeledImageDatasetExporter, foud.ExportPathsMixin
+):
+    """Exporter that writes YOLOv5 datasets to disk.
+
+    See :class:`fiftyone.types.dataset_types.YOLOv5Dataset` for format details.
+
+    Args:
+        export_dir (None): the directory to write the export. This has no
+            effect if ``data_path``, ``objects_path``, and ``images_path`` are
+            absolute paths
+        split ("val"): the split being exported. Typical values are
+            ``("train", "val")``
+        data_path (None): an optional parameter that enables explicit control
+            over the location of the exported media. Can be any of the
+            following:
+
+            -   a folder name like ``"images"`` or ``"images/"`` specifying a
+                subfolder of ``export_dir`` in which to export the images
+            -   an absolute directory path in which to export the images. In
+                this case, the ``export_dir`` has no effect on the location of
+                the images
+
+            If None, the data will be written into ``export_dir`` using the
+            default folder name
+        labels_path (None): an optional parameter that enables explicit
+            control over the location of the exported labels. Can be any of the
+            following:
+
+            -   a folder name like ``"labels"`` or ``"labels/"`` specifying the
+                location in ``export_dir`` in which to export the labels
+            -   an absolute folder path to which to export the labels. In this
+                case, the ``export_dir`` has no effect on the location of
+                the object names
+
+            If None, the object names will be written into ``export_dir``
+            using the default filename
+        yaml_path (None): an optional parameter that enables explicit control
+            over the location of the dataset listing YAML file. Can be any of
+            the following:
+
+            -   a filename like ``"dataset.yaml"`` specifying the location in
+                ``export_dir`` to write the YAML file
+            -   an absolute filepath to which to write the YAML file. In this
+                case, the ``export_dir`` has no effect on the location of
+                the image listing
+
+            If None, the image listing will be written into ``export_dir``
+            using the default filename
+        export_media (None): controls how to export the raw media. The
+            supported values are:
+
+            -   ``True``: copy all media files into the output directory
+            -   ``False``: don't export media
+            -   ``"move"``: move all media files into the output directory
+            -   ``"symlink"``: create symlinks to the media files in the output
+                directory
+
+            If None, the default value of this parameter will be chosen based
+            on the value of the ``data_path`` parameter
+        classes (None): the list of possible class labels. If not provided,
+            this list will be extracted when :meth:`log_collection` is called,
+            if possible
+        image_format (None): the image format to use when writing in-memory
+            images to disk. By default, ``fiftyone.config.default_image_ext``
+            is used
+    """
+
+    def __init__(
+        self,
+        export_dir=None,
+        split="val",
+        data_path=None,
+        labels_path=None,
+        yaml_path=None,
+        export_media=None,
+        classes=None,
+        image_format=None,
+    ):
+        data_path, export_media = self._parse_data_path(
+            export_dir=export_dir,
+            data_path=data_path,
+            export_media=export_media,
+            default="images/" + split,
+        )
+
+        labels_path = self._parse_labels_path(
+            export_dir=export_dir,
+            labels_path=labels_path,
+            default="labels/" + split,
+        )
+
+        yaml_path = self._parse_labels_path(
+            export_dir=export_dir,
+            labels_path=yaml_path,
+            default="dataset.yaml",
+        )
+
+        super().__init__(export_dir=export_dir)
+
+        self.split = split
+        self.data_path = data_path
+        self.labels_path = labels_path
+        self.yaml_path = yaml_path
+        self.export_media = export_media
+        self.classes = classes
+        self.image_format = image_format
+
+        self._classes = None
+        self._dynamic_classes = classes is None
+        self._labels_map_rev = None
+        self._rel_dir = None
+        self._images = None
+        self._writer = None
+        self._media_exporter = None
+
+    @property
+    def requires_image_metadata(self):
+        return False
+
+    @property
+    def label_cls(self):
+        return fol.Detections
+
+    def setup(self):
+        self._classes = {}
+        self._labels_map_rev = {}
+        self._images = []
         self._writer = YOLOAnnotationWriter()
 
-        etau.ensure_dir(self._data_dir)
         self._parse_classes()
 
+        self._media_exporter = foud.ImageExporter(
+            self.export_media,
+            export_path=self.data_path,
+            supported_modes=(True, False, "move", "symlink"),
+            default_ext=self.image_format,
+            ignore_exts=True,
+        )
+        self._media_exporter.setup()
+
     def log_collection(self, sample_collection):
         if self.classes is None:
             if sample_collection.default_classes:
@@ -252,16 +742,12 @@ def log_collection(self, sample_collection):
                 self._dynamic_classes = False
 
     def export_sample(self, image_or_path, detections, metadata=None):
-        out_image_path = self._export_image_or_path(
-            image_or_path, self._filename_maker
-        )
+        _, uuid = self._media_exporter.export(image_or_path)
 
         if detections is None:
             return
 
-        self._images.append(os.path.relpath(out_image_path, self.export_dir))
-
-        out_labels_path = os.path.splitext(out_image_path)[0] + ".txt"
+        out_labels_path = os.path.join(self.labels_path, uuid + ".txt")
 
         self._writer.write(
             detections,
@@ -271,13 +757,23 @@ def export_sample(self, image_or_path, detections, metadata=None):
         )
 
     def close(self, *args):
+        if os.path.isfile(self.yaml_path):
+            d = _read_yaml_file(self.yaml_path)
+        else:
+            d = {}
+
         if self._dynamic_classes:
             classes = _to_classes(self._labels_map_rev)
         else:
             classes = self.classes
 
-        _write_file_lines(classes, self._obj_names_path)
-        _write_file_lines(self._images, self._images_path)
+        d[self.split] = _make_yolo_v5_data_path(self.data_path, self.yaml_path)
+        d["nc"] = len(classes)
+        d["names"] = list(classes)
+
+        _write_yaml_file(d, self.yaml_path)
+
+        self._media_exporter.close()
 
     def _parse_classes(self):
         if self.classes is not None:
@@ -285,10 +781,7 @@ def _parse_classes(self):
 
 
 class YOLOAnnotationWriter(object):
-    """Class for writing annotations in YOLO format.
-
-    See :class:`fiftyone.types.dataset_types.YOLODataset` for format details.
-    """
+    """Class for writing annotations in YOLO-style TXT format."""
 
     def write(
         self, detections, txt_path, labels_map_rev, dynamic_classes=False
@@ -312,9 +805,7 @@ def write(
 
 
 def load_yolo_annotations(txt_path, classes):
-    """Loads the YOLO annotations from the given TXT file.
-
-    See :class:`fiftyone.types.dataset_types.YOLODataset` for format details.
+    """Loads the YOLO-style annotations from the given TXT file.
 
     Args:
         txt_path: the path to the annotations TXT file
@@ -331,6 +822,40 @@ 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
+
+    # Interpret relative to YAML file
+    root_dir = os.path.dirname(yaml_path)
+    return os.path.normpath(os.path.join(root_dir, data_path))
+
+
+def _make_yolo_v5_data_path(data_path, 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
+
+    return data_path
+
+
+def _get_yolo_v5_labels_path(image_path):
+    old = os.path.sep + "images" + os.path.sep
+    new = os.path.sep + "labels" + os.path.sep
+
+    chunks = image_path.rsplit(old, 1)
+    if len(chunks) == 1:
+        raise ValueError(
+            "Invalid image path '%s'. YOLOv5 image paths must contain '%s', "
+            "which is replaced with '%s' to locate the labels TXT file"
+            % (image_path, old, new)
+        )
+
+    return os.path.splitext(new.join(chunks))[0] + ".txt"
+
+
 def _parse_yolo_row(row, classes):
     target, xc, yc, w, h = row.split()
 
@@ -364,6 +889,16 @@ def _make_yolo_row(detection, labels_map_rev, dynamic_classes):
     return "%d %f %f %f %f" % (target, xc, yc, w, h)
 
 
+def _read_yaml_file(path):
+    with open(path, "r") as f:
+        return yaml.safe_load(f)
+
+
+def _write_yaml_file(d, path):
+    s = yaml.dump(d, default_flow_style=False)
+    etau.write_file(s, path)
+
+
 def _read_file_lines(path):
     with open(path, "r") as f:
         lines = [l.strip() for l in f.read().splitlines()]
diff --git a/fiftyone/zoo/datasets/__init__.py b/fiftyone/zoo/datasets/__init__.py
index 539a87829dc..a7227538137 100644
--- a/fiftyone/zoo/datasets/__init__.py
+++ b/fiftyone/zoo/datasets/__init__.py
@@ -17,6 +17,7 @@
 
 import fiftyone as fo
 import fiftyone.core.utils as fou
+import fiftyone.utils.data as foud
 
 
 logger = logging.getLogger(__name__)
@@ -75,7 +76,7 @@ def download_zoo_dataset(
     dataset_dir=None,
     overwrite=False,
     cleanup=True,
-    **kwargs
+    **kwargs,
 ):
     """Downloads the dataset of the given name from the FiftyOne Dataset Zoo.
 
@@ -125,13 +126,14 @@ def load_zoo_dataset(
     name,
     split=None,
     splits=None,
+    label_field=None,
     dataset_name=None,
     dataset_dir=None,
     download_if_necessary=True,
     drop_existing_dataset=False,
     overwrite=False,
     cleanup=None,
-    **kwargs
+    **kwargs,
 ):
     """Loads the dataset of the given name from the FiftyOne Dataset Zoo as
     a :class:`fiftyone.core.dataset.Dataset`.
@@ -156,6 +158,10 @@ def load_zoo_dataset(
             ``splits`` are provided, all available splits are loaded. Consult
             the documentation for the :class:`ZooDataset` you specified to see
             the supported splits
+        label_field (None): the label field (or prefix, if the dataset contains
+            multiple label fields) in which to store the dataset's labels. By
+            default, this is ``"ground_truth"`` if the dataset contains one
+            label field and ``""`` otherwise
         dataset_name (None): an optional name to give the returned
             :class:`fiftyone.core.dataset.Dataset`. By default, a name will be
             constructed based on the dataset and split(s) you are loading
@@ -182,7 +188,7 @@ def load_zoo_dataset(
 
     if download_if_necessary:
         zoo_dataset_cls = _get_zoo_dataset_cls(name)
-        download_kwargs, _ = fou.extract_kwargs_for_class(
+        download_kwargs, kwargs = fou.extract_kwargs_for_class(
             zoo_dataset_cls, kwargs
         )
 
@@ -192,7 +198,7 @@ def load_zoo_dataset(
             dataset_dir=dataset_dir,
             overwrite=overwrite,
             cleanup=cleanup,
-            **download_kwargs
+            **download_kwargs,
         )
         zoo_dataset = info.get_zoo_dataset()
     else:
@@ -200,16 +206,37 @@ def load_zoo_dataset(
         info = zoo_dataset.load_info(dataset_dir)
 
     dataset_type = info.get_dataset_type()
-    dataset_importer = dataset_type.get_dataset_importer_cls()
-    importer_kwargs, _ = fou.extract_kwargs_for_class(dataset_importer, kwargs)
+    dataset_importer_cls = dataset_type.get_dataset_importer_cls()
+
+    #
+    # For unlabeled (e.g., test) splits, some importers need to be explicitly
+    # told to generate samples for media with no corresponding labels entry.
+    #
+    # By convention, all such importers use `include_all_data` for this flag.
+    # If a new zoo dataset is added that requires a different customized
+    # parameter, we'd need to improve this logic here
+    #
+    kwargs["include_all_data"] = True
+
+    importer_kwargs, unused_kwargs = fou.extract_kwargs_for_class(
+        dataset_importer_cls, kwargs
+    )
+
+    for key, value in unused_kwargs.items():
+        if key != "include_all_data" and value is not None:
+            logger.warning(
+                "Ignoring unsupported parameter '%s' for importer type %s",
+                key,
+                dataset_importer_cls,
+            )
 
     if dataset_name is None:
         dataset_name = zoo_dataset.name
         if splits is not None:
             dataset_name += "-" + "-".join(splits)
 
-        if "max_samples" in kwargs:
-            dataset_name += "-%s" % kwargs["max_samples"]
+        if "max_samples" in importer_kwargs:
+            dataset_name += "-%s" % importer_kwargs["max_samples"]
 
     if fo.dataset_exists(dataset_name):
         if not drop_existing_dataset:
@@ -228,29 +255,23 @@ def load_zoo_dataset(
         splits = zoo_dataset.supported_splits
 
     dataset = fo.Dataset(dataset_name)
-    label_field = zoo_dataset.default_label_field
 
     if splits:
         for split in splits:
-            split_dir = zoo_dataset.get_split_dir(dataset_dir, split)
-            tags = [split]
-
             logger.info("Loading '%s' split '%s'", zoo_dataset.name, split)
-            dataset.add_dir(
-                split_dir,
-                dataset_type,
-                tags=tags,
-                label_field=label_field,
-                **importer_kwargs
+            split_dir = zoo_dataset.get_split_dir(dataset_dir, split)
+            dataset_importer, _label_field = _build_importer(
+                dataset_type, split_dir, label_field, **importer_kwargs
+            )
+            dataset.add_importer(
+                dataset_importer, label_field=_label_field, tags=[split]
             )
     else:
         logger.info("Loading '%s'", zoo_dataset.name)
-        dataset.add_dir(
-            dataset_dir,
-            dataset_type,
-            label_field=label_field,
-            **importer_kwargs
+        dataset_importer, _label_field = _build_importer(
+            dataset_type, dataset_dir, label_field, **importer_kwargs
         )
+        dataset.add_importer(dataset_importer, label_field=_label_field)
 
     if info.classes is not None:
         dataset.default_classes = info.classes
@@ -261,6 +282,26 @@ def load_zoo_dataset(
     return dataset
 
 
+def _build_importer(dataset_type, dataset_dir, label_field, **kwargs):
+    dataset_importer, _ = foud.build_dataset_importer(
+        dataset_type, dataset_dir=dataset_dir, **kwargs
+    )
+
+    if label_field is None:
+        try:
+            label_cls = dataset_importer.label_cls
+        except AttributeError:
+            label_cls = None
+
+        # If we're importing multiple fields, don't add a prefix, for brevity
+        if isinstance(label_cls, dict):
+            label_field = ""
+        else:
+            label_field = "ground_truth"
+
+    return dataset_importer, label_field
+
+
 def find_zoo_dataset(name, split=None):
     """Returns the directory containing the given zoo dataset.
 
@@ -775,13 +816,6 @@ def requires_manual_download(self):
         """
         return False
 
-    @property
-    def default_label_field(self):
-        """The default name (or root name in the case of multiple label fields)
-        to use for label field(s) populated when loading this dataset.
-        """
-        return "ground_truth"
-
     def has_tag(self, tag):
         """Whether the dataset has the given tag.
 
diff --git a/fiftyone/zoo/datasets/base.py b/fiftyone/zoo/datasets/base.py
index 9ecb58c48bb..08e895dfcf7 100644
--- a/fiftyone/zoo/datasets/base.py
+++ b/fiftyone/zoo/datasets/base.py
@@ -135,7 +135,7 @@ def _download_and_prepare(self, dataset_dir, scratch_dir, split):
         # Get metadata
         logger.info("Parsing dataset metadata")
         dataset_type = fot.BDDDataset()
-        num_samples = foub.BDDDatasetImporter.get_num_samples(split_dir)
+        num_samples = foub.BDDDatasetImporter._get_num_samples(split_dir)
         logger.info("Found %d samples", num_samples)
 
         return dataset_type, num_samples, None
@@ -205,8 +205,8 @@ def _download_and_prepare(self, dataset_dir, scratch_dir, _):
         logger.info("Parsing dataset metadata")
         dataset_type = fot.ImageClassificationDirectoryTree()
         importer = foud.ImageClassificationDirectoryTreeImporter
-        classes = importer.get_classes(dataset_dir)
-        num_samples = importer.get_num_samples(dataset_dir)
+        classes = importer._get_classes(dataset_dir)
+        num_samples = importer._get_num_samples(dataset_dir)
         logger.info("Found %d samples", num_samples)
 
         return dataset_type, num_samples, classes
@@ -300,8 +300,8 @@ def _download_and_prepare(self, dataset_dir, scratch_dir, _):
         logger.info("Parsing dataset metadata")
         dataset_type = fot.ImageClassificationDirectoryTree()
         importer = foud.ImageClassificationDirectoryTreeImporter
-        classes = importer.get_classes(dataset_dir)
-        num_samples = importer.get_num_samples(dataset_dir)
+        classes = importer._get_classes(dataset_dir)
+        num_samples = importer._get_num_samples(dataset_dir)
         logger.info("Found %d samples", num_samples)
 
         return dataset_type, num_samples, classes
@@ -416,7 +416,7 @@ def _download_and_prepare(self, dataset_dir, scratch_dir, split):
         # Get metadata
         logger.info("Parsing dataset metadata")
         dataset_type = fot.FiftyOneDataset()
-        num_samples = foud.FiftyOneDatasetImporter.get_num_samples(split_dir)
+        num_samples = foud.FiftyOneDatasetImporter._get_num_samples(split_dir)
         logger.info("Found %d samples", num_samples)
 
         return dataset_type, num_samples, None
@@ -619,8 +619,8 @@ def _download_and_prepare(self, dataset_dir, scratch_dir, split):
         logger.info("Parsing dataset metadata")
         dataset_type = fot.VideoClassificationDirectoryTree()
         importer = foud.VideoClassificationDirectoryTreeImporter
-        classes = importer.get_classes(split_dir)
-        num_samples = importer.get_num_samples(split_dir)
+        classes = importer._get_classes(split_dir)
+        num_samples = importer._get_num_samples(split_dir)
         logger.info("Found %d samples", num_samples)
 
         return dataset_type, num_samples, classes
@@ -714,8 +714,8 @@ def _download_and_prepare(self, dataset_dir, scratch_dir, _):
         logger.info("Parsing dataset metadata")
         dataset_type = fot.FiftyOneImageClassificationDataset()
         importer = foud.FiftyOneImageClassificationDatasetImporter
-        classes = importer.get_classes(dataset_dir)
-        num_samples = importer.get_num_samples(dataset_dir)
+        classes = importer._get_classes(dataset_dir)
+        num_samples = importer._get_num_samples(dataset_dir)
         logger.info("Found %d samples", num_samples)
 
         return dataset_type, num_samples, classes
@@ -777,7 +777,7 @@ def _download_and_prepare(self, dataset_dir, scratch_dir, split):
         logger.info("Parsing dataset metadata")
         dataset_type = fot.KITTIDetectionDataset()
         importer = fouk.KITTIDetectionDatasetImporter
-        num_samples = importer.get_num_samples(dataset_dir)
+        num_samples = importer._get_num_samples(dataset_dir)
         logger.info("Found %d samples", num_samples)
 
         return dataset_type, num_samples, None
@@ -839,10 +839,10 @@ def _download_and_prepare(self, dataset_dir, scratch_dir, split):
         dataset_type = fot.ImageClassificationDirectoryTree()
         importer = foud.ImageClassificationDirectoryTreeImporter
         classes = sorted(
-            importer.get_classes(os.path.join(dataset_dir, "train"))
-            + importer.get_classes(os.path.join(dataset_dir, "test"))
+            importer._get_classes(os.path.join(dataset_dir, "train"))
+            + importer._get_classes(os.path.join(dataset_dir, "test"))
         )
-        num_samples = importer.get_num_samples(split_dir)
+        num_samples = importer._get_num_samples(split_dir)
         logger.info("Found %d samples", num_samples)
 
         return dataset_type, num_samples, classes
@@ -976,10 +976,6 @@ def supported_splits(self):
     def supports_partial_download(self):
         return True
 
-    @property
-    def default_label_field(self):
-        return ""
-
     def _download_and_prepare(self, dataset_dir, scratch_dir, split):
         num_samples, classes = fouo.download_open_images_split(
             dataset_dir=dataset_dir,
@@ -1050,8 +1046,8 @@ def _download_and_prepare(self, dataset_dir, scratch_dir, _):
         logger.info("Parsing dataset metadata")
         dataset_type = fot.FiftyOneDataset()
         importer = foud.FiftyOneDatasetImporter
-        classes = importer.get_classes(dataset_dir)
-        num_samples = importer.get_num_samples(dataset_dir)
+        classes = importer._get_classes(dataset_dir)
+        num_samples = importer._get_num_samples(dataset_dir)
         logger.info("Found %d samples", num_samples)
 
         return dataset_type, num_samples, classes
@@ -1105,8 +1101,8 @@ def _download_and_prepare(self, dataset_dir, scratch_dir, _):
         logger.info("Parsing dataset metadata")
         dataset_type = fot.FiftyOneDataset()
         importer = foud.FiftyOneDatasetImporter
-        classes = importer.get_classes(dataset_dir)
-        num_samples = importer.get_num_samples(dataset_dir)
+        classes = importer._get_classes(dataset_dir)
+        num_samples = importer._get_num_samples(dataset_dir)
         logger.info("Found %d samples", num_samples)
 
         return dataset_type, num_samples, classes
@@ -1158,7 +1154,7 @@ def _download_and_prepare(self, dataset_dir, scratch_dir, _):
 
         logger.info("Parsing dataset metadata")
         dataset_type = fot.FiftyOneVideoLabelsDataset()
-        num_samples = foud.FiftyOneVideoLabelsDatasetImporter.get_num_samples(
+        num_samples = foud.FiftyOneVideoLabelsDatasetImporter._get_num_samples(
             dataset_dir
         )
         logger.info("Found %d samples", num_samples)
@@ -1244,8 +1240,8 @@ def _download_and_prepare(self, dataset_dir, scratch_dir, split):
         logger.info("Parsing dataset metadata")
         dataset_type = fot.VideoClassificationDirectoryTree()
         importer = foud.VideoClassificationDirectoryTreeImporter
-        classes = importer.get_classes(split_dir)
-        num_samples = importer.get_num_samples(split_dir)
+        classes = importer._get_classes(split_dir)
+        num_samples = importer._get_num_samples(split_dir)
         logger.info("Found %d samples", num_samples)
 
         return dataset_type, num_samples, classes
diff --git a/fiftyone/zoo/datasets/tf.py b/fiftyone/zoo/datasets/tf.py
index e2e46df800a..2ded8719948 100644
--- a/fiftyone/zoo/datasets/tf.py
+++ b/fiftyone/zoo/datasets/tf.py
@@ -768,10 +768,7 @@ def _download_and_prepare(
 
     # Write the formatted dataset to `dataset_dir`
     foud.write_dataset(
-        samples,
-        sample_parser,
-        dataset_exporter=dataset_exporter,
-        num_samples=num_samples,
+        samples, sample_parser, dataset_exporter, num_samples=num_samples
     )
 
     return dataset_type, num_samples, classes
diff --git a/fiftyone/zoo/datasets/torch.py b/fiftyone/zoo/datasets/torch.py
index 225f03ce676..f51fb53394f 100644
--- a/fiftyone/zoo/datasets/torch.py
+++ b/fiftyone/zoo/datasets/torch.py
@@ -638,10 +638,7 @@ def _download_and_prepare(
 
     # Write the formatted dataset to `dataset_dir`
     foud.write_dataset(
-        dataset,
-        sample_parser,
-        dataset_exporter=dataset_exporter,
-        num_samples=num_samples,
+        dataset, sample_parser, dataset_exporter, num_samples=num_samples
     )
 
     return dataset_type, num_samples, classes
diff --git a/requirements/common.txt b/requirements/common.txt
index 5884d2228bb..a307367630a 100644
--- a/requirements/common.txt
+++ b/requirements/common.txt
@@ -15,6 +15,7 @@ plotly==4.14.3
 pprintpp==0.4.0
 psutil==5.7.0
 pymongo==3.11.0
+PyYAML==5.3.1
 scikit-learn==0.23.2
 scikit-image==0.16.2
 setuptools==45.2.0
diff --git a/setup.py b/setup.py
index b53f969769d..7bf40cb18b5 100644
--- a/setup.py
+++ b/setup.py
@@ -85,6 +85,7 @@ def get_version():
         "pprintpp",
         "psutil",
         "pymongo<4,>=3.11",
+        "PyYAML",
         "retrying",
         "scikit-learn",
         "scikit-image",
diff --git a/tests/intensive/import_export_tests.py b/tests/intensive/import_export_tests.py
index f8b892b8d3f..57df51385b5 100644
--- a/tests/intensive/import_export_tests.py
+++ b/tests/intensive/import_export_tests.py
@@ -248,12 +248,21 @@ def _run_custom_imports(
         export_dir=export_dir, dataset_type=dataset_type, **kwargs
     )
 
-    # Test `skip_unlabeled` when importing
+    # Test unlabeled sample handling when importing
     if num_unlabeled is not None:
+        # Some formats require `include_all_data` in order to load unlabeled
+        # samples. If the format doesn't support this flag, it will be ignored
         _dataset = fo.Dataset.from_dir(
-            export_dir, dataset_type, skip_unlabeled=True
+            export_dir, dataset_type, include_all_data=True
         )
-        assert len(_dataset) == len(sample_collection) - num_unlabeled
+
+        schema = _dataset.get_field_schema()
+        label_field = [f for f in schema if f.startswith("ground_truth")][0]
+
+        num_samples = len(_dataset)
+        num_labeled = len(_dataset.exists(label_field))
+
+        assert num_samples == num_labeled + num_unlabeled
 
     # Test `shuffle` and `max_samples` when importing
     if max_samples is not None:
@@ -328,9 +337,15 @@ def test_detection_datasets(basedir, img):
     dataset.export(export_dir, dataset_type=dataset_type)
     dataset2 = fo.Dataset.from_dir(export_dir, dataset_type)
 
-    # YOLODataset
-    export_dir = os.path.join(basedir, "yolo")
-    dataset_type = fo.types.YOLODataset
+    # YOLOv4Dataset
+    export_dir = os.path.join(basedir, "yolov4")
+    dataset_type = fo.types.YOLOv4Dataset
+    dataset.export(export_dir, dataset_type=dataset_type)
+    dataset2 = fo.Dataset.from_dir(export_dir, dataset_type)
+
+    # YOLOv5Dataset
+    export_dir = os.path.join(basedir, "yolov5")
+    dataset_type = fo.types.YOLOv5Dataset
     dataset.export(export_dir, dataset_type=dataset_type)
     dataset2 = fo.Dataset.from_dir(export_dir, dataset_type)
 
@@ -536,9 +551,17 @@ def test_labeled_datasets_with_no_labels(basedir, img):
     )
     fo.Dataset.from_dir(export_dir, dataset_type)
 
-    # YOLODataset
-    export_dir = os.path.join(basedir, "YOLODataset")
-    dataset_type = fo.types.YOLODataset
+    # YOLOv4Dataset
+    export_dir = os.path.join(basedir, "YOLOv4Dataset")
+    dataset_type = fo.types.YOLOv4Dataset
+    dataset.export(
+        export_dir, label_field="ground_truth", dataset_type=dataset_type
+    )
+    fo.Dataset.from_dir(export_dir, dataset_type)
+
+    # YOLOv5Dataset
+    export_dir = os.path.join(basedir, "YOLOv5Dataset")
+    dataset_type = fo.types.YOLOv5Dataset
     dataset.export(
         export_dir, label_field="ground_truth", dataset_type=dataset_type
     )
@@ -628,7 +651,7 @@ def test_custom_classification_dataset_imports(basedir):
         max_samples=100,
     )
 
-    # Remove labeles from some samples
+    # Remove labels from some samples
     for s in cdataset.take(10):
         s.ground_truth = None
         s.save()
@@ -651,7 +674,8 @@ def test_custom_detection_dataset_imports(basedir):
         fo.types.COCODetectionDataset,
         fo.types.VOCDetectionDataset,
         fo.types.KITTIDetectionDataset,
-        fo.types.YOLODataset,
+        fo.types.YOLOv4Dataset,
+        fo.types.YOLOv5Dataset,
         fo.types.TFObjectDetectionDataset,
         fo.types.CVATImageDataset,
     ]
@@ -663,9 +687,10 @@ def test_custom_detection_dataset_imports(basedir):
         dataset_name="detection-dataset",
         shuffle=True,
         max_samples=100,
+        num_workers=1,  # pytest crashes without this
     )
 
-    # Remove labeles from some samples
+    # Remove labels from some samples
     for s in ddataset.take(10):
         s.ground_truth = None
         s.save()
@@ -705,16 +730,18 @@ def test_custom_multitask_image_dataset_imports(basedir):
 
     # Load a small multitask image dataset
     idataset = foz.load_zoo_dataset(
-        "coco-2017",
+        "open-images-v6",
         split="validation",
+        label_types=["classifications", "detections"],
         dataset_name="image-labels-dataset",
         shuffle=True,
         max_samples=100,
+        num_workers=1,  # pytest crashes without this
     )
 
-    # Remove labeles from some samples
+    # Remove labels from some samples
     for s in idataset.take(10):
-        s.ground_truth = None
+        s.detections = None
         s.save()
 
     # Test custom imports
@@ -732,6 +759,7 @@ def test_custom_generic_dataset_imports(basedir):
     # Types of generic datasets to test
     dataset_types = [
         fo.types.dataset_types.LegacyFiftyOneDataset,
+        fo.types.dataset_types.FiftyOneDataset,
     ]
 
     # Load a small generic dataset


Code review comments:
diff_hunk: @@ -47,13 +47,50 @@ a |DatasetView| into any format of your choice via the basic recipe below.
 
         # Export the dataset!
         dataset_or_view.export(
-            export_dir=export_dir, dataset_type=dataset_type, label_field=label_field
+            export_dir=export_dir,
+            dataset_type=dataset_type,
+            label_field=label_field,
         )
 
     Note the `label_field` argument in the above example, which specifies the
-    particular label field that you wish to export. This is necessary your
+    particular label field that you wish to export. This is necessary if your
     FiftyOne dataset contains multiple label fields.
 
+    The :meth:`export() <fiftyone.core.collections.SampleCollection.export>`
+    method also provides additional parameters that you can use to configure
+    the export, such as the `export_media` parameter, which allows you to
+    configure whether to copy, move, symlink, or omit the media files from
+    the export:
+
+    .. code-block:: python
+        :linenos:
+
+        #
+        # Variation #1
+        #
+        # Export the labels in the `ground_truth` field in COCO format, and
+        # write a `data.json` file describing the locations of the source media
+        # rather than exporting the actual media
+        #
+        dataset_or_view.export(
+            export_dir="/path/for/export",
+            dataset_type=fo.types.COCODetectionDataset,
+            label_field="ground_truth",
+            export_media=False,
path: docs/source/user_guide/export_datasets.rst
text: @user1:
Shouldn't this be `export_media="manifest"` if you say it is writing a `data.json` file?

@author:
yep you're correct; forgot to update this documentation 😅