Repository: voxel51/fiftyone

Issue:
Exporting/importing a FiftyOneDataset with an annotation run results in an error
### System information

-   **OS Platform and Distribution (e.g., Linux Ubuntu 16.04)**: Windows 10
-   **FiftyOne installed from (pip or source)**: pip
-   **FiftyOne version (run `fiftyone --version`)**: 0.14.0
-   **Python version**: Python 3.8.10

### Describe the problem

Exporting a dataset with `dataset_type=fo.types.FiftyOneDataset` with at least one annotation run and trying to import it back results in an error.

I am sorry if the issue is caused by not using the interface correctly. We just started using fiftyone in our organization and I am currently working to incorporate it into our workflow.

### Code to reproduce issue
```
import fiftyone as fo
import fiftyone.zoo as foz

dirname = "C:/Users/User/test_dataset_export"

if fo.dataset_exists("quickstart"):
    fo.delete_dataset("quickstart")
dataset = foz.load_zoo_dataset("quickstart")

view = dataset.take(1)
anno_key = "test_export_import_dataset_with_annotation"
view.annotate(
    anno_key,
    label_field="ground_truth",
    url="http://localhost:8080",
    username='********',
    password='********'
    )

dataset.export(
    export_dir=dirname,
    dataset_type=fo.types.FiftyOneDataset,
    export_media=False,
    )

if fo.dataset_exists('another_dataset_name'):
    fo.delete_dataset('another_dataset_name')
new_dataset = fo.Dataset.from_dir(
    dataset_dir=dirname,
    dataset_type=fo.types.FiftyOneDataset,
    name='another_dataset_name',
    )
```

### Other info / logs

```python traceback
Traceback (most recent call last):

  File "C:\Users\User\geostack\ctspec_ai\bug_fiftyone_annotate_export_import.py", line 35, in <module>
    new_dataset = fo.Dataset.from_dir(

  File "C:\Python38\lib\site-packages\fiftyone\core\dataset.py", line 3525, in from_dir
    dataset.add_dir(

  File "C:\Python38\lib\site-packages\fiftyone\core\dataset.py", line 2319, in add_dir
    return self.add_importer(

  File "C:\Python38\lib\site-packages\fiftyone\core\dataset.py", line 2859, in add_importer
    return foud.import_samples(

  File "C:\Python38\lib\site-packages\fiftyone\utils\data\importers.py", line 112, in import_samples
    return dataset_importer.import_samples(dataset, tags=tags)

  File "C:\Python38\lib\site-packages\fiftyone\utils\data\importers.py", line 1458, in import_samples
    return self._import_samples(dataset, dataset_dict, tags=tags)

  File "C:\Python38\lib\site-packages\fiftyone\utils\data\importers.py", line 1570, in _import_samples
    _import_run_results(

  File "C:\Python38\lib\site-packages\fiftyone\utils\data\importers.py", line 1641, in _import_run_results
    results = fors.RunResults.from_json(json_path, dataset)

  File "C:\Python38\lib\site-packages\eta\core\serial.py", line 512, in from_json
    return cls.from_dict(read_json(path), *args, **kwargs)

TypeError: from_dict() missing 1 required positional argument: 'config'
```

### What areas of FiftyOne does this bug affect?

-   [ ] `App`: FiftyOne application issue
-   [x] `Core`: Core `fiftyone` Python library issue
-   [ ] `Server`: Fiftyone server issue

### Willingness to contribute

The FiftyOne Community encourages bug fix contributions. Would you or another
member of your organization be willing to contribute a fix for this bug to the
FiftyOne codebase?

-   [ ] Yes. I can contribute a fix for this bug independently.
-   [x] Yes. I would be willing to contribute a fix for this bug with guidance
        from the FiftyOne community.
-   [ ] No. I cannot contribute a bug fix at this time.



Discussion:
From my limited understanding of the code at the moment, it seems like this might be resolved by fetching the run config in `fiftyone.utils.data.importers._import_run_results` for each key and pass it to `fors.RunResults.from_json`, such that:

https://github.com/voxel51/fiftyone/blob/0f113175174ec1d4cd2f901a43317bb817847b53/fiftyone/utils/data/importers.py#L1634-L1642

would be instead:

```python
def _import_run_results(dataset, run_dir, run_cls, keys=None):
    if keys is None:
        keys = [os.path.splitext(f)[0] for f in etau.list_files(run_dir)]

    for key in keys:
        json_path = os.path.join(run_dir, key + ".json")
        if os.path.exists(json_path):
            config = run_cls.get_run_info(dataset, key).config
            results = fors.RunResults.from_json(json_path, dataset, config)
            run_cls.save_run_results(dataset, key, results)
```

Resolving patch:
diff --git a/fiftyone/core/dataset.py b/fiftyone/core/dataset.py
--- a/fiftyone/core/dataset.py
+++ b/fiftyone/core/dataset.py
@@ -4993,9 +4993,13 @@ def _clone_runs(dst_dataset, src_doc):
     for anno_key, run_doc in src_doc.annotation_runs.items():
         _run_doc = deepcopy(run_doc)
         dst_doc.annotation_runs[anno_key] = _run_doc
-        results = foan.AnnotationMethod.load_run_results(dst_dataset, anno_key)
+        results = foan.AnnotationMethod.load_run_results(
+            dst_dataset, anno_key, cache=False
+        )
         _run_doc.results = None
-        foan.AnnotationMethod.save_run_results(dst_dataset, anno_key, results)
+        foan.AnnotationMethod.save_run_results(
+            dst_dataset, anno_key, results, cache=False
+        )
 
     #
     # Clone brain results
@@ -5008,9 +5012,13 @@ def _clone_runs(dst_dataset, src_doc):
     for brain_key, run_doc in src_doc.brain_methods.items():
         _run_doc = deepcopy(run_doc)
         dst_doc.brain_methods[brain_key] = _run_doc
-        results = fob.BrainMethod.load_run_results(dst_dataset, brain_key)
+        results = fob.BrainMethod.load_run_results(
+            dst_dataset, brain_key, cache=False
+        )
         _run_doc.results = None
-        fob.BrainMethod.save_run_results(dst_dataset, brain_key, results)
+        fob.BrainMethod.save_run_results(
+            dst_dataset, brain_key, results, cache=False
+        )
 
     #
     # Clone evaluation results
@@ -5023,9 +5031,13 @@ def _clone_runs(dst_dataset, src_doc):
     for eval_key, run_doc in src_doc.evaluations.items():
         _run_doc = deepcopy(run_doc)
         dst_doc.evaluations[eval_key] = _run_doc
-        results = foe.EvaluationMethod.load_run_results(dst_dataset, eval_key)
+        results = foe.EvaluationMethod.load_run_results(
+            dst_dataset, eval_key, cache=False
+        )
         _run_doc.results = None
-        foe.EvaluationMethod.save_run_results(dst_dataset, eval_key, results)
+        foe.EvaluationMethod.save_run_results(
+            dst_dataset, eval_key, results, cache=False
+        )
 
     dst_doc.save()
 
diff --git a/fiftyone/core/runs.py b/fiftyone/core/runs.py
--- a/fiftyone/core/runs.py
+++ b/fiftyone/core/runs.py
@@ -385,7 +385,9 @@ def update_run_config(cls, samples, key, config):
         dataset._doc.save()
 
     @classmethod
-    def save_run_results(cls, samples, key, run_results, overwrite=True):
+    def save_run_results(
+        cls, samples, key, run_results, overwrite=True, cache=True
+    ):
         """Saves the run results on the collection.
 
         Args:
@@ -394,6 +396,7 @@ def save_run_results(cls, samples, key, run_results, overwrite=True):
             run_results: a :class:`RunResults`, or None
             overwrite (True): whether to overwrite an existing result with the
                 same key
+            cache (True): whether to cache the results on the collection
         """
         if key is None:
             return
@@ -420,18 +423,20 @@ def save_run_results(cls, samples, key, run_results, overwrite=True):
             run_doc.results.put(results_bytes, content_type="application/json")
 
         # Cache the results for future use in this session
-        results_cache = getattr(dataset, cls._results_cache_field())
-        results_cache[key] = run_results
+        if cache:
+            results_cache = getattr(dataset, cls._results_cache_field())
+            results_cache[key] = run_results
 
         dataset._doc.save()
 
     @classmethod
-    def load_run_results(cls, samples, key):
+    def load_run_results(cls, samples, key, cache=True):
         """Loads the :class:`RunResults` for the given key on the collection.
 
         Args:
             samples: a :class:`fiftyone.core.collections.SampleCollection`
             key: a run key
+            cache (True): whether to cache the results on the collection
 
         Returns:
             a :class:`RunResults`, or None if the run did not save results
@@ -479,7 +484,8 @@ def load_run_results(cls, samples, key):
             ) from e
 
         # Cache the results for future use in this session
-        results_cache[key] = run_results
+        if cache:
+            results_cache[key] = run_results
 
         return run_results
 
diff --git a/fiftyone/utils/data/importers.py b/fiftyone/utils/data/importers.py
--- a/fiftyone/utils/data/importers.py
+++ b/fiftyone/utils/data/importers.py
@@ -1638,8 +1638,11 @@ def _import_run_results(dataset, run_dir, run_cls, keys=None):
     for key in keys:
         json_path = os.path.join(run_dir, key + ".json")
         if os.path.exists(json_path):
-            results = fors.RunResults.from_json(json_path, dataset)
-            run_cls.save_run_results(dataset, key, results)
+            view = run_cls.load_run_view(dataset, key)
+            run_info = run_cls.get_run_info(dataset, key)
+            config = run_info.config
+            results = fors.RunResults.from_json(json_path, view, config)
+            run_cls.save_run_results(dataset, key, results, cache=False)
 
 
 class ImageDirectoryImporter(UnlabeledImageDatasetImporter):


Test patch:
diff --git a/tests/unittests/import_export_tests.py b/tests/unittests/import_export_tests.py
--- a/tests/unittests/import_export_tests.py
+++ b/tests/unittests/import_export_tests.py
@@ -1559,6 +1559,35 @@ def test_fiftyone_dataset(self):
             dataset2.count("predictions.detections"),
         )
 
+        # Test import/export of run results
+
+        dataset.clone_sample_field("predictions", "ground_truth")
+
+        view = dataset.limit(2)
+        view.evaluate_detections(
+            "predictions", gt_field="ground_truth", eval_key="test"
+        )
+
+        export_dir = self._new_dir()
+
+        dataset.export(
+            export_dir=export_dir, dataset_type=fo.types.FiftyOneDataset,
+        )
+
+        dataset2 = fo.Dataset.from_dir(
+            dataset_dir=export_dir, dataset_type=fo.types.FiftyOneDataset,
+        )
+
+        self.assertTrue("test" in dataset.list_evaluations())
+        self.assertTrue("test" in dataset2.list_evaluations())
+
+        view2 = dataset2.load_evaluation_view("test")
+        self.assertEqual(len(view), len(view2))
+
+        info = dataset.get_evaluation_info("test")
+        info2 = dataset2.get_evaluation_info("test")
+        self.assertEqual(info.key, info2.key)
+
 
 class VideoDatasetTests(unittest.TestCase):
     def setUp(self):