From 7778a303c81e30ee0abae2cd0c7df684ebf90818 Mon Sep 17 00:00:00 2001 From: brimoor Date: Tue, 10 Oct 2023 08:58:19 -0400 Subject: [PATCH 01/36] linting release notes --- docs/source/release-notes.rst | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/docs/source/release-notes.rst b/docs/source/release-notes.rst index e2b2c590a2..27ba3fa125 100644 --- a/docs/source/release-notes.rst +++ b/docs/source/release-notes.rst @@ -3,7 +3,6 @@ FiftyOne Release Notes .. default-role:: code - .. _release-notes-teams-v1.4.2: FiftyOne Teams 1.4.2 @@ -18,7 +17,8 @@ General key on datasets a user does not have access to - Fixed issue with setting default access permissions for new datasets - Deleting a dataset now deletes all dataset-related references -- Default fields now populate properly when creating a new dataset regardless of client +- Default fields now populate properly when creating a new dataset regardless + of client - Improved complex/multi collection aggregations in the api client - Fixed issue where users could not list other users within their own org - Snapshots now properly include all run results @@ -44,35 +44,43 @@ App `#3605 `_ - Fixed a bug with color by index for videos `#3606 `_ -- Fixed an issue where |Detections| (and other label types) subfields were properly handling - primitive types. +- Fixed an issue where |Detections| (and other label types) subfields were + properly handling primitive types `#3577 `_ +- Fixed an issue launching the App in Databrick notebooks + `#3609 `_ Core - Resolved groups aggregation issue resulting in unstable ordering of documents `#3641 `_ -- Fixed an issue where group id indexes were not created against the right id property +- Fixed an issue where group indexes were not created against the correct `id` + property `#3627 `_ -- Fixed fiftyone app cells in Databrick notebooks - `#3609 `_ -- Fixed issue with empty segmentation mask conversion in coco formatted datasets +- Fixed issue with empty segmentation mask conversion in COCO-formatted datasets `#3595 `_ Plugins -- Added a new :mod:`fiftyone.plugins.utils` module that provides common utilities for plugin development +- Added a new :mod:`fiftyone.plugins.utils` module that provides common + utilities for plugin development `#3612 `_ - Re-enabled text-only placement support when icon is not available `#3593 `_ -- Added read-only support for :class:`FileExplorerView ` +- Added read-only support for + :class:`FileExplorerView ` `#3639 `_ -- The ``fiftyone delegated launch`` CLI command will now only run one operation at a time +- The ``fiftyone delegated launch`` CLI command will now only run one operation + at a time `#3615 `_ - Fixed an issue where custom component props were not supported `#3595 `_ -- Fixed issue where ``selected_labels`` were missing from the :class:`ExecutionContext ` - during ``resolve_input`` and ``resolve_output``. +- Fixed issue where ``selected_labels`` were missing from the + :class:`ExecutionContext ` + during + :meth:`resolve_input() ` + and + :meth:`resolve_output() ` `#3575 `_ .. _release-notes-teams-v1.4.1: From bcc5dab55b743e7fbed8b413c34c14be5569e254 Mon Sep 17 00:00:00 2001 From: brimoor Date: Tue, 10 Oct 2023 09:04:26 -0400 Subject: [PATCH 02/36] bumping versions --- fiftyone/constants.py | 2 +- package/desktop/setup.py | 2 +- setup.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/fiftyone/constants.py b/fiftyone/constants.py index a2e0ac4245..b88c670504 100644 --- a/fiftyone/constants.py +++ b/fiftyone/constants.py @@ -41,7 +41,7 @@ # This setting may be ``None`` if this client has no compatibility with other # versions # -COMPATIBLE_VERSIONS = ">=0.19,<0.22" +COMPATIBLE_VERSIONS = ">=0.19,<0.23" # Package metadata _META = metadata("fiftyone") diff --git a/package/desktop/setup.py b/package/desktop/setup.py index ab4dd90cfe..5384e47c4f 100644 --- a/package/desktop/setup.py +++ b/package/desktop/setup.py @@ -16,7 +16,7 @@ import shutil -VERSION = "0.30.2" +VERSION = "0.30.3" def get_version(): diff --git a/setup.py b/setup.py index 90b3178279..dcffcef187 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ from setuptools import setup, find_packages -VERSION = "0.22.1" +VERSION = "0.22.2" def get_version(): @@ -113,7 +113,7 @@ def get_install_requirements(install_requires, choose_install_requires): return install_requires -EXTRAS_REQUIREMENTS = {"desktop": ["fiftyone-desktop>=0.29,<0.30"]} +EXTRAS_REQUIREMENTS = {"desktop": ["fiftyone-desktop>=0.30,<0.31"]} with open("README.md", "r") as fh: From 6da894f8a50633967bec6d623362e8bdd7e3c62e Mon Sep 17 00:00:00 2001 From: brimoor Date: Tue, 10 Oct 2023 11:11:19 -0400 Subject: [PATCH 03/36] adding support for directly exporting object patches --- docs/source/user_guide/export_datasets.rst | 30 ++++++++++++-- fiftyone/utils/data/exporters.py | 38 +++++++++++------- fiftyone/utils/patches.py | 18 +++++++-- tests/unittests/import_export_tests.py | 46 ++++++++++++++++++++-- 4 files changed, 105 insertions(+), 27 deletions(-) diff --git a/docs/source/user_guide/export_datasets.rst b/docs/source/user_guide/export_datasets.rst index 926aa3b223..2925f9a914 100644 --- a/docs/source/user_guide/export_datasets.rst +++ b/docs/source/user_guide/export_datasets.rst @@ -264,6 +264,30 @@ exported. label_field="ground_truth", ) +You can also directly call +:meth:`export() ` on +:ref:`patches views ` to export the specified object +patches along with their appropriately typed labels. + +.. code-block:: python + :linenos: + + # Continuing from above... + + patches = dataset.to_patches("ground_truth") + + # Export the object patches as a directory of images + patches.export( + export_dir="/tmp/quickstart/also-patches", + dataset_type=fo.types.ImageDirectory, + ) + + # Export the object patches as an image classification directory tree + patches.export( + export_dir="/tmp/quickstart/also-objects", + dataset_type=fo.types.ImageClassificationDirectoryTree, + ) + Video clips ~~~~~~~~~~~ @@ -278,7 +302,7 @@ specified :ref:`video clips ` will be exported. import fiftyone as fo import fiftyone.zoo as foz - dataset = foz.load_zoo_dataset("quickstart-video").limit(2).clone() + dataset = foz.load_zoo_dataset("quickstart-video", max_samples=2) # Add some temporal detections to the dataset sample1 = dataset.first() @@ -333,12 +357,10 @@ their appropriately typed labels. dataset_type=fo.types.VideoDirectory, ) - # A classification field is provided, so the clips are exported as a video - # classification directory tree + # Export the clips as a video classification directory tree clips.export( export_dir="/tmp/quickstart-video/clip-classifications", dataset_type=fo.types.VideoClassificationDirectoryTree, - label_field="events", ) # Export the clips along with their associated frame labels diff --git a/fiftyone/utils/data/exporters.py b/fiftyone/utils/data/exporters.py index 55fe2e6d2d..8487237191 100644 --- a/fiftyone/utils/data/exporters.py +++ b/fiftyone/utils/data/exporters.py @@ -247,7 +247,7 @@ def export_samples( # Export unlabeled image patches samples = foup.ImagePatchesExtractor( samples, - label_field, + patches_field=label_field, include_labels=False, **patches_kwargs, ) @@ -259,7 +259,7 @@ def export_samples( ) elif isinstance(dataset_exporter, UnlabeledVideoDatasetExporter): - if found_clips: + if found_clips and not samples._is_clips: # Export unlabeled video clips samples = samples.to_clips(label_field) num_samples = len(samples) @@ -276,7 +276,7 @@ def export_samples( # Note that if the dataset exporter does not use `export_media`, this # will not work properly... # - if samples._dataset._is_clips and _export_media: + if _export_media and samples._is_clips: dataset_exporter.export_media = "move" sample_parser = FiftyOneUnlabeledVideoSampleParser( @@ -293,7 +293,7 @@ def export_samples( # Export labeled image patches samples = foup.ImagePatchesExtractor( samples, - label_field, + patches_field=label_field, include_labels=True, **patches_kwargs, ) @@ -310,7 +310,7 @@ def export_samples( ) elif isinstance(dataset_exporter, LabeledVideoDatasetExporter): - if found_clips: + if found_clips and not samples._is_clips: # Export labeled video clips samples = samples.to_clips(label_field) num_samples = len(samples) @@ -327,7 +327,7 @@ def export_samples( # Note that if the dataset exporter does not use `export_media`, this # will not work properly... # - if samples._dataset._is_clips and _export_media: + if _export_media and samples._is_clips: dataset_exporter.export_media = "move" label_fcn = _make_label_coercion_functions( @@ -513,10 +513,8 @@ def _check_for_patches_export(samples, dataset_exporter, label_field, kwargs): else: label_field = None - if label_field is None: - return False, {}, kwargs - found_patches = False + patches_kwargs = {} if isinstance(dataset_exporter, UnlabeledImageDatasetExporter): try: @@ -532,7 +530,12 @@ def _check_for_patches_export(samples, dataset_exporter, label_field, kwargs): label_field, label_type, ) - + elif samples._is_patches: + found_patches = True + logger.info( + "Detected an unlabeled image exporter and a patches view. " + "Exporting image patches...", + ) elif isinstance(dataset_exporter, LabeledImageDatasetExporter): label_cls = dataset_exporter.label_cls @@ -570,8 +573,6 @@ def _check_for_patches_export(samples, dataset_exporter, label_field, kwargs): patches_kwargs, kwargs = fou.extract_kwargs_for_class( foup.ImagePatchesExtractor, kwargs ) - else: - patches_kwargs = {} return found_patches, patches_kwargs, kwargs @@ -602,12 +603,17 @@ def _check_for_clips_export(samples, dataset_exporter, label_field, kwargs): label_field, label_type, ) + elif samples._is_clips: + found_clips = True + logger.info( + "Detected an unlabeled video exporter and a clips view. " + "Exporting video clips...", + ) - if found_clips or samples._dataset._is_clips: + if found_clips: clips_kwargs, kwargs = fou.extract_kwargs_for_class( FiftyOneUnlabeledVideoSampleParser, kwargs ) - elif isinstance(dataset_exporter, LabeledVideoDatasetExporter): label_cls = dataset_exporter.label_cls @@ -642,8 +648,10 @@ def _check_for_clips_export(samples, dataset_exporter, label_field, kwargs): label_field, label_type, ) + elif samples._is_clips: + found_clips = True - if found_clips or samples._dataset._is_clips: + if found_clips: clips_kwargs, kwargs = fou.extract_kwargs_for_class( FiftyOneLabeledVideoSampleParser, kwargs ) diff --git a/fiftyone/utils/patches.py b/fiftyone/utils/patches.py index ff5e4638c4..60fec9b958 100644 --- a/fiftyone/utils/patches.py +++ b/fiftyone/utils/patches.py @@ -23,12 +23,13 @@ class ImagePatchesExtractor(object): Args: samples: a :class:`fiftyone.core.collections.SampleCollection` - patches_field: the name of the field defining the image patches in each - sample to extract. Must be of type + patches_field (None): the name of the field defining the image patches + in each sample to extract. Must be of type :class:`fiftyone.core.labels.Detection`, :class:`fiftyone.core.labels.Detections`, :class:`fiftyone.core.labels.Polyline`, or - :class:`fiftyone.core.labels.Polylines` + :class:`fiftyone.core.labels.Polylines`. This can be automatically + inferred (only) if ``samples`` is a patches view include_labels (False): whether to emit ``(img_patch, label)`` tuples rather than just image patches force_rgb (False): whether to force convert the images to RGB @@ -45,12 +46,21 @@ class ImagePatchesExtractor(object): def __init__( self, samples, - patches_field, + patches_field=None, include_labels=False, force_rgb=False, force_square=False, alpha=None, ): + if patches_field is None: + if samples._is_patches: + patches_field = samples._label_fields[0] + else: + raise ValueError( + "You must provide a 'patches_field' when 'samples' is not " + "a patches view" + ) + self.samples = samples self.patches_field = patches_field self.include_labels = include_labels diff --git a/tests/unittests/import_export_tests.py b/tests/unittests/import_export_tests.py index ab1aa3e58d..6dc0f72f63 100644 --- a/tests/unittests/import_export_tests.py +++ b/tests/unittests/import_export_tests.py @@ -282,6 +282,45 @@ def test_patch_exports(self): len(dataset3), dataset.count("ground_truth.detections") ) + # + # A patches view is provided, so object patches are exported as images + # + + export_dir4 = self._new_dir() + + patches = dataset.to_patches("ground_truth") + patches.export( + export_dir=export_dir4, + dataset_type=fo.types.ImageDirectory, + ) + + dataset2 = fo.Dataset.from_dir( + dataset_dir=export_dir4, + dataset_type=fo.types.ImageDirectory, + ) + + self.assertEqual(len(dataset2), len(patches)) + + # + # A patches view is provided, so the object patches are exported as an + # image classification directory tree + # + + export_dir5 = self._new_dir() + + patches = dataset.to_patches("ground_truth") + patches.export( + export_dir=export_dir5, + dataset_type=fo.types.ImageClassificationDirectoryTree, + ) + + dataset2 = fo.Dataset.from_dir( + dataset_dir=export_dir5, + dataset_type=fo.types.ImageClassificationDirectoryTree, + ) + + self.assertEqual(len(dataset2), len(patches)) + @drop_datasets def test_single_label_to_lists(self): sample = fo.Sample( @@ -3703,15 +3742,15 @@ def test_clip_exports(self): ) # - # Export video classification clips directly from a ClipsView + # Export video classification clips directly from a clips view # export_dir = self._new_dir() - dataset.to_clips("predictions").export( + clips = dataset.to_clips("predictions") + clips.export( export_dir=export_dir, dataset_type=fo.types.VideoClassificationDirectoryTree, - label_field="predictions", ) dataset2 = fo.Dataset.from_dir( @@ -3753,7 +3792,6 @@ def test_clip_exports(self): export_dir = self._new_dir() clips = dataset.to_clips("predictions") - clips.export( export_dir=export_dir, dataset_type=fo.types.FiftyOneDataset ) From e82d608381fad67836326458ec8f504785b83199 Mon Sep 17 00:00:00 2001 From: brimoor Date: Tue, 10 Oct 2023 11:16:39 -0400 Subject: [PATCH 04/36] test direct clip export as well --- tests/unittests/import_export_tests.py | 29 ++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/tests/unittests/import_export_tests.py b/tests/unittests/import_export_tests.py index 6dc0f72f63..2dbf4ec436 100644 --- a/tests/unittests/import_export_tests.py +++ b/tests/unittests/import_export_tests.py @@ -299,7 +299,9 @@ def test_patch_exports(self): dataset_type=fo.types.ImageDirectory, ) - self.assertEqual(len(dataset2), len(patches)) + self.assertEqual( + len(dataset2), dataset.count("ground_truth.detections") + ) # # A patches view is provided, so the object patches are exported as an @@ -319,7 +321,9 @@ def test_patch_exports(self): dataset_type=fo.types.ImageClassificationDirectoryTree, ) - self.assertEqual(len(dataset2), len(patches)) + self.assertEqual( + len(dataset2), dataset.count("ground_truth.detections") + ) @drop_datasets def test_single_label_to_lists(self): @@ -3741,6 +3745,27 @@ def test_clip_exports(self): len(dataset2), dataset.count("predictions.detections") ) + # + # Export video clips directly from a clips view + # + + export_dir = self._new_dir() + + clips = dataset.to_clips("predictions") + clips.export( + export_dir=export_dir, + dataset_type=fo.types.VideoDirectory, + ) + + dataset2 = fo.Dataset.from_dir( + dataset_dir=export_dir, + dataset_type=fo.types.VideoDirectory, + ) + + self.assertEqual( + len(dataset2), dataset.count("predictions.detections") + ) + # # Export video classification clips directly from a clips view # From db59641f4cbde6a1f58f2a9541ed34740e46c901 Mon Sep 17 00:00:00 2001 From: brimoor Date: Tue, 10 Oct 2023 11:48:46 -0400 Subject: [PATCH 05/36] removing deprecated note --- fiftyone/utils/coco.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/fiftyone/utils/coco.py b/fiftyone/utils/coco.py index 07f39f9095..a42ae9764a 100644 --- a/fiftyone/utils/coco.py +++ b/fiftyone/utils/coco.py @@ -615,9 +615,6 @@ class COCODetectionDatasetExporter( ): """Exporter that writes COCO detection datasets to disk. - This class currently only supports exporting detections and instance - segmentations. - See :ref:`this page ` for format details. Args: From 46e25d31eca32f12d9938f21bebc5177cfbd7f80 Mon Sep 17 00:00:00 2001 From: brimoor Date: Wed, 11 Oct 2023 10:50:56 -0400 Subject: [PATCH 06/36] standardizing processing of num_workers --- docs/source/cli/index.rst | 6 +- docs/source/user_guide/config.rst | 8 +++ fiftyone/core/cli.py | 10 +--- fiftyone/core/collections.py | 3 +- fiftyone/core/config.py | 7 ++- fiftyone/core/metadata.py | 8 +-- fiftyone/core/storage.py | 13 ++--- fiftyone/core/utils.py | 97 ++++++++++++++++++++++--------- fiftyone/plugins/utils.py | 7 ++- fiftyone/utils/activitynet.py | 5 +- fiftyone/utils/aws.py | 8 +-- fiftyone/utils/beam.py | 16 ++--- fiftyone/utils/coco.py | 9 +-- fiftyone/utils/cvat.py | 8 +-- fiftyone/utils/data/base.py | 16 ++--- fiftyone/utils/image.py | 10 +--- fiftyone/utils/kinetics.py | 5 +- fiftyone/utils/kitti.py | 19 ++++-- fiftyone/utils/openimages.py | 5 +- fiftyone/utils/sama.py | 12 +++- fiftyone/utils/youtube.py | 22 +++---- fiftyone/zoo/datasets/base.py | 55 +++++++----------- 22 files changed, 185 insertions(+), 164 deletions(-) diff --git a/docs/source/cli/index.rst b/docs/source/cli/index.rst index ffc43ce92e..579e7d9c74 100644 --- a/docs/source/cli/index.rst +++ b/docs/source/cli/index.rst @@ -1506,8 +1506,7 @@ Populates the `metadata` field of all samples in the dataset. -h, --help show this help message and exit -o, --overwrite whether to overwrite existing metadata -n NUM_WORKERS, --num-workers NUM_WORKERS - the number of worker processes to use. The default - is `multiprocessing.cpu_count()` + a suggested number of worker processes to use -s, --skip-failures whether to gracefully continue without raising an error if metadata cannot be computed for a sample @@ -1583,8 +1582,7 @@ Transforms the images in a dataset per the specified parameters. -d, --delete-originals whether to delete the original images after transforming -n NUM_WORKERS, --num-workers NUM_WORKERS - the number of worker processes to use. The default is - `multiprocessing.cpu_count()` + a suggested number of worker processes to use -s, --skip-failures whether to gracefully continue without raising an error if an image cannot be transformed diff --git a/docs/source/user_guide/config.rst b/docs/source/user_guide/config.rst index b8edc55746..846dfa7383 100644 --- a/docs/source/user_guide/config.rst +++ b/docs/source/user_guide/config.rst @@ -79,6 +79,10 @@ FiftyOne supports the configuration options described below: | `logging_level` | `FIFTYONE_LOGGING_LEVEL` | `INFO` | Controls FiftyOne's package-wide logging level. Can be any valid ``logging`` level as | | | | | a string: ``DEBUG, INFO, WARNING, ERROR, CRITICAL``. | +-------------------------------+-------------------------------------+-------------------------------+----------------------------------------------------------------------------------------+ +| `max_thread_pool_workers` | `FIFTYONE_MAX_THREAD_POOL_WORKERS` | `None` | An optional maximum number of workers to use when creating thread pools | ++-------------------------------+-------------------------------------+-------------------------------+----------------------------------------------------------------------------------------+ +| `max_process_pool_workers` | `FIFTYONE_MAX_PROCESS_POOL_WORKERS` | `None` | An optional maximum number of workers to use when creating process pools | ++-------------------------------+-------------------------------------+-------------------------------+----------------------------------------------------------------------------------------+ | `model_zoo_dir` | `FIFTYONE_MODEL_ZOO_DIR` | `~/fiftyone/__models__` | The default directory in which to store models that are downloaded from the | | | | | :ref:`FiftyOne Model Zoo `. | +-------------------------------+-------------------------------------+-------------------------------+----------------------------------------------------------------------------------------+ @@ -151,6 +155,8 @@ and the CLI: "desktop_app": false, "do_not_track": false, "logging_level": "INFO", + "max_thread_pool_workers": null, + "max_process_pool_workers": null, "model_zoo_dir": "~/fiftyone/__models__", "model_zoo_manifest_paths": null, "module_path": null, @@ -196,6 +202,8 @@ and the CLI: "desktop_app": false, "do_not_track": false, "logging_level": "INFO", + "max_thread_pool_workers": null, + "max_process_pool_workers": null, "model_zoo_dir": "~/fiftyone/__models__", "model_zoo_manifest_paths": null, "module_path": null, diff --git a/fiftyone/core/cli.py b/fiftyone/core/cli.py index 243f8f0f80..44b32b16e7 100644 --- a/fiftyone/core/cli.py +++ b/fiftyone/core/cli.py @@ -3751,10 +3751,7 @@ def setup(parser): "--num-workers", default=None, type=int, - help=( - "the number of worker processes to use. The default is " - "`multiprocessing.cpu_count()`" - ), + help="a suggested number of worker processes to use", ) parser.add_argument( "-s", @@ -3896,10 +3893,7 @@ def setup(parser): "--num-workers", default=None, type=int, - help=( - "the number of worker processes to use. The default is " - "`multiprocessing.cpu_count()`" - ), + help="a suggested number of worker processes to use", ) parser.add_argument( "-s", diff --git a/fiftyone/core/collections.py b/fiftyone/core/collections.py index 98bd5cfe93..bfef83a5cf 100644 --- a/fiftyone/core/collections.py +++ b/fiftyone/core/collections.py @@ -2666,8 +2666,7 @@ def compute_metadata( Args: overwrite (False): whether to overwrite existing metadata - num_workers (None): the number of processes to use. By default, - ``multiprocessing.cpu_count()`` is used + num_workers (None): a suggested number of processes to use skip_failures (True): whether to gracefully continue without raising an error if metadata cannot be computed for a sample """ diff --git a/fiftyone/core/config.py b/fiftyone/core/config.py index 2db9fbdd7a..1985215685 100644 --- a/fiftyone/core/config.py +++ b/fiftyone/core/config.py @@ -218,13 +218,18 @@ def __init__(self, d=None): self.timezone = self.parse_string( d, "timezone", env_var="FIFTYONE_TIMEZONE", default=None ) - self.max_thread_pool_workers = self.parse_int( d, "max_thread_pool_workers", env_var="FIFTYONE_MAX_THREAD_POOL_WORKERS", default=None, ) + self.max_process_pool_workers = self.parse_int( + d, + "max_process_pool_workers", + env_var="FIFTYONE_MAX_PROCESS_POOL_WORKERS", + default=None, + ) self._init() diff --git a/fiftyone/core/metadata.py b/fiftyone/core/metadata.py index c761ada8e9..848ebf7363 100644 --- a/fiftyone/core/metadata.py +++ b/fiftyone/core/metadata.py @@ -7,7 +7,6 @@ """ import itertools import logging -import multiprocessing import os import requests @@ -16,6 +15,7 @@ import eta.core.utils as etau import eta.core.video as etav +import fiftyone as fo from fiftyone.core.odm import DynamicEmbeddedDocument import fiftyone.core.fields as fof import fiftyone.core.media as fom @@ -240,13 +240,11 @@ def compute_metadata( sample_collection: a :class:`fiftyone.core.collections.SampleCollection` overwrite (False): whether to overwrite existing metadata - num_workers (None): the number of processes to use. By default, - ``multiprocessing.cpu_count()`` is used + num_workers (None): a suggested number of processes to use skip_failures (True): whether to gracefully continue without raising an error if metadata cannot be computed for a sample """ - if num_workers is None: - num_workers = multiprocessing.cpu_count() + num_workers = fou.recommend_process_pool_workers(num_workers) if sample_collection.media_type == fom.GROUP: sample_collection = sample_collection.select_group_slices( diff --git a/fiftyone/core/storage.py b/fiftyone/core/storage.py index bea096bc28..68ab5322ee 100644 --- a/fiftyone/core/storage.py +++ b/fiftyone/core/storage.py @@ -827,16 +827,14 @@ def run(fcn, tasks, num_workers=None, progress=False): Args: fcn: a function that accepts a single argument tasks: an iterable of function aguments - num_workers (None): the number of threads to use. By default, - ``multiprocessing.cpu_count()`` is used + num_workers (None): a suggested number of threads to use progress (False): whether to render a progress bar tracking the status of the operation Returns: the list of function outputs """ - if num_workers is None: - num_workers = multiprocessing.cpu_count() + num_workers = fou.recommend_thread_pool_workers(num_workers) try: num_tasks = len(tasks) @@ -845,7 +843,7 @@ def run(fcn, tasks, num_workers=None, progress=False): kwargs = dict(total=num_tasks, iters_str="files", quiet=not progress) - if not num_workers or num_workers <= 1: + if num_workers <= 1: with fou.ProgressBar(**kwargs) as pb: results = [fcn(task) for task in pb(tasks)] else: @@ -863,8 +861,7 @@ def _copy_files(inpaths, outpaths, skip_failures, progress): def _run(fcn, tasks, num_workers=None, progress=False): - if num_workers is None: - num_workers = multiprocessing.cpu_count() + num_workers = fou.recommend_thread_pool_workers(num_workers) try: num_tasks = len(tasks) @@ -873,7 +870,7 @@ def _run(fcn, tasks, num_workers=None, progress=False): kwargs = dict(total=num_tasks, iters_str="files", quiet=not progress) - if not num_workers or num_workers <= 1: + if num_workers <= 1: with fou.ProgressBar(**kwargs) as pb: for task in pb(tasks): fcn(task) diff --git a/fiftyone/core/utils.py b/fiftyone/core/utils.py index acad81e990..1b85d3629f 100644 --- a/fiftyone/core/utils.py +++ b/fiftyone/core/utils.py @@ -5,7 +5,6 @@ | `voxel51.com `_ | """ -import typing as t import atexit from base64 import b64encode, b64decode from collections import defaultdict @@ -1691,6 +1690,77 @@ def get_multiprocessing_context(): return multiprocessing.get_context() +def recommend_thread_pool_workers(num_workers=None): + """Recommends a number of workers for a thread pool. + + If a ``fo.config.max_thread_pool_workers`` is set, this limit is applied. + + Args: + num_workers (None): a suggested number of workers + + Returns: + a number of workers + """ + if num_workers is None: + num_workers = multiprocessing.cpu_count() + + if fo.config.max_thread_pool_workers is not None: + num_workers = min(num_workers, fo.config.max_thread_pool_workers) + + return num_workers + + +def recommend_process_pool_workers(num_workers=None): + """Recommends a number of workers for a process pool. + + If a ``fo.config.max_process_pool_workers`` is set, this limit is applied. + + Args: + num_workers (None): a suggested number of workers + + Returns: + a number of workers + """ + if num_workers is None: + if sys.platform.startswith("win"): + # Windows tends to have multiprocessing issues + num_workers = 1 + else: + num_workers = multiprocessing.cpu_count() + + if fo.config.max_process_pool_workers is not None: + num_workers = min(num_workers, fo.config.max_process_pool_workers) + + return num_workers + + +sync_task_executor = None + + +def _get_sync_task_executor(): + global sync_task_executor + + max_workers = fo.config.max_thread_pool_workers + if sync_task_executor is None and max_workers is not None: + sync_task_executor = ThreadPoolExecutor(max_workers=max_workers) + + return sync_task_executor + + +async def run_sync_task(func, *args): + """Run a synchronous function as an async background task. + + Args: + func: a synchronous callable + *args: function arguments + + Returns: + the function's return value(s) + """ + loop = asyncio.get_running_loop() + return await loop.run_in_executor(_get_sync_task_executor(), func, *args) + + def datetime_to_timestamp(dt): """Converts a `datetime.date` or `datetime.datetime` to milliseconds since epoch. @@ -1876,31 +1946,6 @@ def to_slug(name): return slug -_T = t.TypeVar("_T") - -sync_task_executor = None - - -def get_sync_task_executor(): - global sync_task_executor - max_workers = fo.config.max_thread_pool_workers - if sync_task_executor is None and max_workers is not None: - sync_task_executor = ThreadPoolExecutor(max_workers=max_workers) - return sync_task_executor - - -async def run_sync_task(func: t.Callable[..., _T], *args: t.Any): - """ - Run a synchronous function as an async background task - - Args: - run: a synchronous callable - """ - loop = asyncio.get_running_loop() - - return await loop.run_in_executor(get_sync_task_executor(), func, *args) - - def validate_color(value): """Validates that the given value is a valid css color name. diff --git a/fiftyone/plugins/utils.py b/fiftyone/plugins/utils.py index fc27256cda..e93ef4ca0e 100644 --- a/fiftyone/plugins/utils.py +++ b/fiftyone/plugins/utils.py @@ -6,12 +6,13 @@ | """ import logging -import multiprocessing +import multiprocessing.dummy import os from bs4 import BeautifulSoup import yaml +import fiftyone.core.utils as fou from fiftyone.utils.github import GitHubRepository from fiftyone.plugins.core import PLUGIN_METADATA_FILENAMES @@ -196,8 +197,10 @@ def _get_all_plugin_info(tasks): if num_tasks == 1: return [_do_get_plugin_info(tasks[0])] + num_workers = fou.recommend_thread_pool_workers(min(num_tasks, 4)) + info = [] - with multiprocessing.dummy.Pool(processes=min(num_tasks, 4)) as pool: + with multiprocessing.dummy.Pool(processes=num_workers) as pool: for d in pool.imap_unordered(_do_get_plugin_info, tasks): info.append(d) diff --git a/fiftyone/utils/activitynet.py b/fiftyone/utils/activitynet.py index b6da9ed54c..e8fb18a139 100644 --- a/fiftyone/utils/activitynet.py +++ b/fiftyone/utils/activitynet.py @@ -54,9 +54,8 @@ def download_activitynet_split( copy_files (True): whether to move (False) or create copies (True) of the source files when populating ``dataset_dir``. This is only relevant when a ``source_dir`` is provided - num_workers (None): the number of threads to use when downloading - individual video. By default, ``multiprocessing.cpu_count()`` is - used + num_workers (None): a suggested number of threads to use when + downloading individual videos shuffle (False): whether to randomly shuffle the order in which samples are chosen for partial downloads seed (None): a random seed to use when shuffling diff --git a/fiftyone/utils/aws.py b/fiftyone/utils/aws.py index 305453ae4f..b513f0a4f1 100644 --- a/fiftyone/utils/aws.py +++ b/fiftyone/utils/aws.py @@ -6,7 +6,6 @@ | """ import logging -import multiprocessing import multiprocessing.dummy import os from urllib.parse import urlparse @@ -50,8 +49,8 @@ def download_public_s3_files( the `download_dir` argument is required download_dir (None): the directory to store all downloaded objects. This is only used if `urls` is a list - num_workers (None): the number of processes to use when downloading - files. By default, ``multiprocessing.cpu_count()`` is used + num_workers (None): a suggested number of threads to use when + downloading files overwrite (True): whether to overwrite existing files """ if not isinstance(urls, dict): @@ -65,8 +64,7 @@ def download_public_s3_files( if download_dir: etau.ensure_dir(download_dir) - if num_workers is None: - num_workers = multiprocessing.cpu_count() + num_workers = fou.recommend_thread_pool_workers(num_workers) s3_client = boto3.client( "s3", diff --git a/fiftyone/utils/beam.py b/fiftyone/utils/beam.py index 8985b0fc90..abd1e7478c 100644 --- a/fiftyone/utils/beam.py +++ b/fiftyone/utils/beam.py @@ -6,7 +6,6 @@ | """ import logging -import multiprocessing import numpy as np import fiftyone.core.dataset as fod @@ -89,14 +88,13 @@ def make_sample(idx): options (None): a ``apache_beam.options.pipeline_options.PipelineOptions`` that configures how to run the pipeline. By default, the pipeline will - be run via Beam's direct runner using - ``multiprocessing.cpu_count()`` threads + be run via Beam's direct runner using threads verbose (False): whether to log the Beam pipeline's messages """ if options is None: options = PipelineOptions( runner="direct", - direct_num_workers=multiprocessing.cpu_count(), + direct_num_workers=fou.recommend_thread_pool_workers(), direct_running_mode="multi_threading", ) @@ -183,8 +181,7 @@ def beam_merge( options (None): a ``apache_beam.options.pipeline_options.PipelineOptions`` that configures how to run the pipeline. By default, the pipeline will - be run via Beam's direct runner using - ``multiprocessing.cpu_count()`` threads + be run via Beam's direct runner using threads verbose (False): whether to log the Beam pipeline's messages **kwargs: keyword arguments for :meth:`fiftyone.core.dataset.Dataset.merge_samples` @@ -213,7 +210,7 @@ def beam_merge( if options is None: options = PipelineOptions( runner="direct", - direct_num_workers=multiprocessing.cpu_count(), + direct_num_workers=fou.recommend_thread_pool_workers(), direct_running_mode="multi_threading", ) @@ -290,8 +287,7 @@ def beam_export( options (None): a ``apache_beam.options.pipeline_options.PipelineOptions`` that configures how to run the pipeline. By default, the pipeline will - be run via Beam's direct runner using - ``min(num_shards, multiprocessing.cpu_count())`` processes + be run via Beam's direct runner using multiprocessing verbose (False): whether to log the Beam pipeline's messages render_kwargs (None): a function that renders ``kwargs`` for the current shard. The function should have signature @@ -303,7 +299,7 @@ def beam_export( :meth:`fiftyone.core.collections.SampleCollection.export` """ if options is None: - num_workers = min(num_shards, multiprocessing.cpu_count()) + num_workers = min(num_shards, fou.recommend_process_pool_workers()) options = PipelineOptions( runner="direct", direct_num_workers=num_workers, diff --git a/fiftyone/utils/coco.py b/fiftyone/utils/coco.py index a42ae9764a..6e7080e908 100644 --- a/fiftyone/utils/coco.py +++ b/fiftyone/utils/coco.py @@ -11,7 +11,6 @@ from datetime import datetime from itertools import groupby import logging -import multiprocessing import multiprocessing.dummy import os import random @@ -1482,9 +1481,8 @@ def download_coco_dataset_split( - the path to a text (newline-separated), JSON, or CSV file containing the list of image IDs to load in either of the first two formats - num_workers (None): the number of processes to use when downloading - individual images. By default, ``multiprocessing.cpu_count()`` is - used + num_workers (None): a suggested number of threads to use when + downloading individual images shuffle (False): whether to randomly shuffle the order in which samples are chosen for partial downloads seed (None): a random seed to use when shuffling @@ -1849,8 +1847,7 @@ def _get_existing_ids(images_dir, images, image_ids): def _download_images(images_dir, image_ids, images, num_workers): - if num_workers is None: - num_workers = multiprocessing.cpu_count() + num_workers = fou.recommend_thread_pool_workers(num_workers) tasks = [] for image_id in image_ids: diff --git a/fiftyone/utils/cvat.py b/fiftyone/utils/cvat.py index 202c63f62d..ed4cd5e7fe 100644 --- a/fiftyone/utils/cvat.py +++ b/fiftyone/utils/cvat.py @@ -12,7 +12,6 @@ import itertools import logging import math -import multiprocessing import multiprocessing.dummy import os from packaging.version import Version @@ -118,8 +117,8 @@ def import_annotations( download_media (False): whether to download the images or videos found in CVAT to the directory or filepaths in ``data_path`` if not already present - num_workers (None): the number of processes to use when downloading - media. By default, ``multiprocessing.cpu_count()`` is used + num_workers (None): a suggested number of threads to use when + downloading media occluded_attr (None): an optional attribute name in which to store the occlusion information for all spatial labels group_id_attr (None): an optional attribute name in which to store the @@ -325,8 +324,7 @@ def _parse_task_metadata( def _download_media(tasks, num_workers): - if num_workers is None: - num_workers = multiprocessing.cpu_count() + num_workers = fou.recommend_thread_pool_workers(num_workers) logger.info("Downloading media...") if num_workers <= 1: diff --git a/fiftyone/utils/data/base.py b/fiftyone/utils/data/base.py index 1f01595f43..04b48fafea 100644 --- a/fiftyone/utils/data/base.py +++ b/fiftyone/utils/data/base.py @@ -6,7 +6,7 @@ | """ import logging -import multiprocessing +import multiprocessing.dummy import requests import os import pathlib @@ -118,8 +118,8 @@ def download_image_classification_dataset( dataset_dir: the directory to write the dataset classes (None): an optional list of classes. By default, this will be inferred from the contents of ``csv_path`` - num_workers (None): the number of processes to use to download images. - By default, ``multiprocessing.cpu_count()`` is used + num_workers (None): a suggested number of threads to use to download + images """ labels, image_urls = zip( *[ @@ -161,14 +161,12 @@ def download_images(image_urls, output_dir, num_workers=None): Args: image_urls: a list of image URLs to download output_dir: the directory to write the images - num_workers (None): the number of processes to use. By default, - ``multiprocessing.cpu_count()`` is used + num_workers (None): a suggested number of threads to use Returns: the list of downloaded image paths """ - if num_workers is None: - num_workers = multiprocessing.cpu_count() + num_workers = fou.recommend_thread_pool_workers(num_workers) inputs = [] for url in image_urls: @@ -192,9 +190,7 @@ def _download_images(inputs): def _download_images_multi(inputs, num_workers): with fou.ProgressBar(inputs) as pb: - with fou.get_multiprocessing_context().Pool( - processes=num_workers - ) as pool: + with multiprocessing.dummy.Pool(processes=num_workers) as pool: for _ in pb(pool.imap_unordered(_download_image, inputs)): pass diff --git a/fiftyone/utils/image.py b/fiftyone/utils/image.py index b491cb3a87..44c02ce820 100644 --- a/fiftyone/utils/image.py +++ b/fiftyone/utils/image.py @@ -6,7 +6,6 @@ | """ import logging -import multiprocessing import os import eta.core.image as etai @@ -98,8 +97,7 @@ def reencode_images( sample collection delete_originals (False): whether to delete the original images after re-encoding - num_workers (None): the number of worker processes to use. By default, - ``multiprocessing.cpu_count()`` is used + num_workers (None): a suggested number of worker processes to use skip_failures (False): whether to gracefully continue without raising an error if an image cannot be re-encoded """ @@ -188,8 +186,7 @@ def transform_images( sample collection delete_originals (False): whether to delete the original images if any transformation was applied - num_workers (None): the number of worker processes to use. By default, - ``multiprocessing.cpu_count()`` is used + num_workers (None): a suggested number of worker processes to use skip_failures (False): whether to gracefully continue without raising an error if an image cannot be transformed """ @@ -277,8 +274,7 @@ def _transform_images( ): ext = _parse_ext(ext) - if num_workers is None: - num_workers = multiprocessing.cpu_count() + num_workers = fou.recommend_process_pool_workers(num_workers) if num_workers <= 1: _transform_images_single( diff --git a/fiftyone/utils/kinetics.py b/fiftyone/utils/kinetics.py index b84b29d1e4..f10523c7c3 100644 --- a/fiftyone/utils/kinetics.py +++ b/fiftyone/utils/kinetics.py @@ -48,9 +48,8 @@ def download_kinetics_split( classes (None): a string or list of strings specifying required classes to load. If provided, only samples containing at least one instance of a specified class will be loaded - num_workers (None): the number of processes to use when downloading - individual video. By default, ``multiprocessing.cpu_count()`` is - used + num_workers (None): a suggested number of threads to use when + downloading individual videos shuffle (False): whether to randomly shuffle the order in which samples are chosen for partial downloads seed (None): a random seed to use when shuffling diff --git a/fiftyone/utils/kitti.py b/fiftyone/utils/kitti.py index 6b3a20beff..a8fc7ee450 100644 --- a/fiftyone/utils/kitti.py +++ b/fiftyone/utils/kitti.py @@ -8,7 +8,6 @@ """ import csv import logging -import multiprocessing import struct import os @@ -417,6 +416,7 @@ def download_kitti_multiview_dataset( scratch_dir=None, overwrite=False, cleanup=False, + num_workers=None, ): """Downloads and prepares the multiview KITTI dataset. @@ -464,6 +464,8 @@ def download_kitti_multiview_dataset( already exist cleanup (False): whether to delete the downloaded zips and scratch directory + num_workers (None): a suggested number of processes to use when + converting LiDAR to PCD """ if splits is None: splits = ("test", "train") @@ -530,7 +532,9 @@ def download_kitti_multiview_dataset( for split in splits: split_dir = os.path.join(dataset_dir, split) - _prepare_kitti_split(split_dir, overwrite=overwrite) + _prepare_kitti_split( + split_dir, overwrite=overwrite, num_workers=num_workers + ) if cleanup: etau.delete_dir(scratch_dir) @@ -810,7 +814,7 @@ def _make_kitti_detection_row(detection, frame_size): # -def _prepare_kitti_split(split_dir, overwrite=False): +def _prepare_kitti_split(split_dir, overwrite=False, num_workers=None): samples_path = os.path.join(split_dir, "samples.json") if not overwrite and os.path.isfile(samples_path): return @@ -849,6 +853,7 @@ def _prepare_kitti_split(split_dir, overwrite=False): pcd_dir, uuids, overwrite=overwrite, + num_workers=num_workers, ) left_map = make_map(left_images_dir) @@ -1054,7 +1059,7 @@ def _proj_3d_to_right_camera(detections3d, calib, frame_size): def _convert_velodyne_to_pcd( - velodyne_map, calib_map, pcd_dir, uuids, overwrite=False + velodyne_map, calib_map, pcd_dir, uuids, overwrite=False, num_workers=None ): inputs = [] for uuid in uuids: @@ -1073,9 +1078,11 @@ def _convert_velodyne_to_pcd( etau.ensure_dir(pcd_dir) logger.info("Converting Velodyne scans to PCD format...") - num_workers = multiprocessing.cpu_count() + num_workers = fou.recommend_process_pool_workers(num_workers) with fou.ProgressBar(total=len(inputs)) as pb: - with multiprocessing.Pool(processes=num_workers) as pool: + with fou.get_multiprocessing_context().Pool( + processes=num_workers + ) as pool: for _ in pb(pool.imap_unordered(_do_conversion, inputs)): pass diff --git a/fiftyone/utils/openimages.py b/fiftyone/utils/openimages.py index 1ccdb86355..d1cbfb66f2 100644 --- a/fiftyone/utils/openimages.py +++ b/fiftyone/utils/openimages.py @@ -720,9 +720,8 @@ def download_open_images_split( - the path to a text (newline-separated), JSON, or CSV file containing the list of image IDs to load in either of the first two formats - num_workers (None): the number of processes to use when downloading - individual images. By default, ``multiprocessing.cpu_count()`` is - used + num_workers (None): a suggested number of threads to use when + downloading individual images shuffle (False): whether to randomly shuffle the order in which samples are chosen for partial downloads seed (None): a random seed to use when shuffling diff --git a/fiftyone/utils/sama.py b/fiftyone/utils/sama.py index 5a7072c18f..e23fb98be9 100644 --- a/fiftyone/utils/sama.py +++ b/fiftyone/utils/sama.py @@ -1,3 +1,10 @@ +""" +Sama utilities. + +| Copyright 2017-2023, Voxel51, Inc. +| `voxel51.com `_ +| +""" import os import glob import random @@ -54,9 +61,8 @@ def download_sama_coco_dataset_split( - the path to a text (newline-separated), JSON, or CSV file containing the list of image IDs to load in either of the first two formats - num_workers (None): the number of processes to use when downloading - individual images. By default, ``multiprocessing.cpu_count()`` is - used + num_workers (None): a suggested number of threads to use when + downloading individual images shuffle (False): whether to randomly shuffle the order in which samples are chosen for partial downloads seed (None): a random seed to use when shuffling diff --git a/fiftyone/utils/youtube.py b/fiftyone/utils/youtube.py index a39d9d70ea..7a070e5a4c 100644 --- a/fiftyone/utils/youtube.py +++ b/fiftyone/utils/youtube.py @@ -14,7 +14,6 @@ import itertools import logging -import multiprocessing import multiprocessing.dummy import os @@ -113,9 +112,8 @@ def download_youtube_videos( whose resolution is closest to this target value is downloaded max_videos (None): the maximum number of videos to successfully download. By default, all videos are downloaded - num_workers (None): the number of threads or processes to use when - downloading videos. By default, ``multiprocessing.cpu_count()`` is - used + num_workers (None): a suggested number of threads/processes to use when + downloading videos skip_failures (True): whether to gracefully continue without raising an error if a video cannot be downloaded quiet (False): whether to suppress logging, except for a progress bar @@ -165,14 +163,10 @@ def download_youtube_videos( def _parse_num_workers(num_workers, use_threads=False): - if num_workers is None: - if os.name == "nt" and not use_threads: - # Multiprocessing on Windows is bad news - return 1 + if use_threads: + return fou.recommend_thread_pool_workers(num_workers) - return multiprocessing.cpu_count() - - return num_workers + return fou.recommend_process_pool_workers(num_workers) def _build_tasks_list( @@ -280,11 +274,11 @@ def _download_multi( with fou.ProgressBar(total=max_videos, iters_str="videos") as pb: if use_threads: - pool_cls = multiprocessing.dummy.Pool + ctx = multiprocessing.dummy else: - pool_cls = multiprocessing.Pool + ctx = fou.get_multiprocessing_context() - with pool_cls(num_workers) as pool: + with ctx.Pool(num_workers) as pool: for idx, url, video_path, error, warnings in pool.imap_unordered( _do_download, tasks ): diff --git a/fiftyone/zoo/datasets/base.py b/fiftyone/zoo/datasets/base.py index 03d0730763..1618e17889 100644 --- a/fiftyone/zoo/datasets/base.py +++ b/fiftyone/zoo/datasets/base.py @@ -146,9 +146,8 @@ class version of the dataset. copy_files (True): whether to move (False) or create copies (True) of the source files when populating ``dataset_dir``. This is only relevant when a ``source_dir`` is provided - num_workers (None): the number of processes to use when downloading - individual images. By default, ``multiprocessing.cpu_count()`` is - used + num_workers (None): a suggested number of threads to use when + downloading individual images shuffle (False): whether to randomly shuffle the order in which samples are chosen for partial downloads seed (None): a random seed to use when shuffling @@ -326,9 +325,8 @@ class version of the dataset. copy_files (True): whether to move (False) or create copies (True) of the source files when populating ``dataset_dir``. This is only relevant when a ``source_dir`` is provided - num_workers (None): the number of processes to use when downloading - individual images. By default, ``multiprocessing.cpu_count()`` is - used + num_workers (None): a suggested number of threads to use when + downloading individual images shuffle (False): whether to randomly shuffle the order in which samples are chosen for partial downloads seed (None): a random seed to use when shuffling @@ -892,9 +890,8 @@ class COCO2014Dataset(FiftyOneDataset): - the path to a text (newline-separated), JSON, or CSV file containing the list of image IDs to load in either of the first two formats - num_workers (None): the number of processes to use when downloading - individual images. By default, ``multiprocessing.cpu_count()`` is - used + num_workers (None): a suggested number of threads to use when + downloading individual images shuffle (False): whether to randomly shuffle the order in which samples are chosen for partial downloads seed (None): a random seed to use when shuffling @@ -1087,9 +1084,8 @@ class COCO2017Dataset(FiftyOneDataset): - the path to a text (newline-separated), JSON, or CSV file containing the list of image IDs to load in either of the first two formats - num_workers (None): the number of processes to use when downloading - individual images. By default, ``multiprocessing.cpu_count()`` is - used + num_workers (None): a suggested number of threads to use when + downloading individual images shuffle (False): whether to randomly shuffle the order in which samples are chosen for partial downloads seed (None): a random seed to use when shuffling @@ -1283,9 +1279,8 @@ class SamaCOCODataset(FiftyOneDataset): - the path to a text (newline-separated), JSON, or CSV file containing the list of image IDs to load in either of the first two formats - num_workers (None): the number of processes to use when downloading - individual images. By default, ``multiprocessing.cpu_count()`` is - used + num_workers (None): a suggested number of threads to use when + downloading individual images shuffle (False): whether to randomly shuffle the order in which samples are chosen for partial downloads seed (None): a random seed to use when shuffling @@ -1777,9 +1772,8 @@ class Kinetics400Dataset(FiftyOneDataset): classes (None): a string or list of strings specifying required classes to load. If provided, only samples containing at least one instance of a specified class will be loaded - num_workers (None): the number of processes to use when downloading - individual images. By default, ``multiprocessing.cpu_count()`` is - used + num_workers (None): a suggested number of threads to use when + downloading individual images shuffle (False): whether to randomly shuffle the order in which samples are chosen for partial downloads seed (None): a random seed to use when shuffling @@ -1933,9 +1927,8 @@ class Kinetics600Dataset(FiftyOneDataset): classes (None): a string or list of strings specifying required classes to load. If provided, only samples containing at least one instance of a specified class will be loaded - num_workers (None): the number of processes to use when downloading - individual images. By default, ``multiprocessing.cpu_count()`` is - used + num_workers (None): a suggested number of threads to use when + downloading individual images shuffle (False): whether to randomly shuffle the order in which samples are chosen for partial downloads seed (None): a random seed to use when shuffling @@ -2083,9 +2076,8 @@ class Kinetics700Dataset(FiftyOneDataset): classes (None): a string or list of strings specifying required classes to load. If provided, only samples containing at least one instance of a specified class will be loaded - num_workers (None): the number of processes to use when downloading - individual images. By default, ``multiprocessing.cpu_count()`` is - used + num_workers (None): a suggested number of threads to use when + downloading individual images shuffle (False): whether to randomly shuffle the order in which samples are chosen for partial downloads seed (None): a random seed to use when shuffling @@ -2240,9 +2232,8 @@ class Kinetics7002020Dataset(FiftyOneDataset): classes (None): a string or list of strings specifying required classes to load. If provided, only samples containing at least one instance of a specified class will be loaded - num_workers (None): the number of processes to use when downloading - individual images. By default, ``multiprocessing.cpu_count()`` is - used + num_workers (None): a suggested number of threads to use when + downloading individual images shuffle (False): whether to randomly shuffle the order in which samples are chosen for partial downloads seed (None): a random seed to use when shuffling @@ -2630,9 +2621,8 @@ class OpenImagesV6Dataset(FiftyOneDataset): - the path to a text (newline-separated), JSON, or CSV file containing the list of image IDs to load in either of the first two formats - num_workers (None): the number of processes to use when downloading - individual images. By default, ``multiprocessing.cpu_count()`` is - used + num_workers (None): a suggested number of threads to use when + downloading individual images shuffle (False): whether to randomly shuffle the order in which samples are chosen for partial downloads seed (None): a random seed to use when shuffling @@ -2822,9 +2812,8 @@ class OpenImagesV7Dataset(FiftyOneDataset): - the path to a text (newline-separated), JSON, or CSV file containing the list of image IDs to load in either of the first two formats - num_workers (None): the number of processes to use when downloading - individual images. By default, ``multiprocessing.cpu_count()`` is - used + num_workers (None): a suggested number of threads to use when + downloading individual images shuffle (False): whether to randomly shuffle the order in which samples are chosen for partial downloads seed (None): a random seed to use when shuffling From 6a3f2ff1d017886419628a4bdceb59c17a2f75a7 Mon Sep 17 00:00:00 2001 From: brimoor Date: Wed, 11 Oct 2023 10:57:24 -0400 Subject: [PATCH 07/36] fixing typo --- docs/source/teams/cloud_media.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/teams/cloud_media.rst b/docs/source/teams/cloud_media.rst index 4b702f4167..c3b821cb81 100644 --- a/docs/source/teams/cloud_media.rst +++ b/docs/source/teams/cloud_media.rst @@ -620,7 +620,7 @@ First, follow `these instructions `_ to attach a cloud storage bucket to CVAT. Then, simply provide the `cloud_manifest` parameter to -:meth:`annotate() ` to specify the URL of the manifest file in your cloud bucket: .. code-block:: python From 44efdd0d33c2d539a36dcca7a2217d5a61970312 Mon Sep 17 00:00:00 2001 From: imanjra Date: Wed, 11 Oct 2023 19:00:50 -0400 Subject: [PATCH 08/36] use fallback icon when operator is non-executable --- app/packages/operators/src/OperatorIcon.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/packages/operators/src/OperatorIcon.tsx b/app/packages/operators/src/OperatorIcon.tsx index 404dd61eda..d4ec71e7c9 100644 --- a/app/packages/operators/src/OperatorIcon.tsx +++ b/app/packages/operators/src/OperatorIcon.tsx @@ -3,11 +3,19 @@ import { useColorScheme } from "@mui/material"; import { resolveServerPath } from "./utils"; export default function OperatorIcon(props: CustomIconPropsType) { - const { pluginName, icon, lightIcon, darkIcon, _builtIn, Fallback } = props; + const { + pluginName, + icon, + lightIcon, + darkIcon, + _builtIn, + Fallback, + canExecute, + } = props; const { mode } = useColorScheme(); const iconPath = mode === "dark" && darkIcon ? darkIcon : lightIcon || icon; - if (!iconPath) return Fallback ? : null; + if (!iconPath || !canExecute) return Fallback ? : null; if (_builtIn) return ; return ; } @@ -32,6 +40,7 @@ export type CustomIconPropsType = { darkIcon?: string; _builtIn?: boolean; Fallback?: React.ComponentType; + canExecute?: boolean; }; type CustomOperatorIconPropsType = { From ef7b0d000b390eedd13bd20774e5176480e8a9f1 Mon Sep 17 00:00:00 2001 From: brimoor Date: Mon, 16 Oct 2023 11:21:17 -0500 Subject: [PATCH 09/36] we support databricks --- docs/source/environments/index.rst | 10 ++++++---- docs/source/faq/index.rst | 5 +++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/source/environments/index.rst b/docs/source/environments/index.rst index b342824acc..1993ac3c49 100644 --- a/docs/source/environments/index.rst +++ b/docs/source/environments/index.rst @@ -20,8 +20,9 @@ ___________ the App * :ref:`Notebooks `: You are working from a - `Jupyter Notebook `_ or a - `Google Colab Notebook `_. + `Jupyter Notebook `_, + `Google Colab Notebook `_, or + `Databricks Notebook `_ * :ref:`Cloud storage `: Data is stored in a cloud bucket (e.g., :ref:`S3 `, :ref:`GCS `, or :ref:`Azure `) @@ -189,8 +190,9 @@ or by setting the following environment variable: Notebooks _________ -FiftyOne officialy supports `Jupyter Notebooks `_ and -`Google Colab Notebooks `_. +FiftyOne officialy supports `Jupyter Notebooks `_, +`Google Colab Notebooks `_, and +`Databricks Notebooks `_. To use FiftyOne in a notebook, simply install `fiftyone` via `pip`: diff --git a/docs/source/faq/index.rst b/docs/source/faq/index.rst index 6fcce8b59d..51bfb80031 100644 --- a/docs/source/faq/index.rst +++ b/docs/source/faq/index.rst @@ -109,8 +109,9 @@ See :ref:`this section ` for more details. Can I use FiftyOne in a notebook? --------------------------------- -Yes! FiftyOne supports both `Jupyter Notebooks `_ and -`Google Colab Notebooks `_. +Yes! FiftyOne supports `Jupyter Notebooks `_, +`Google Colab Notebooks `_, and +`Databricks Notebooks `_. All the usual FiftyOne commands can be run in notebook environments, and the App will launch/update in the output of your notebook cells! From f073b909453c746bc9d0dde93297aba7fe8dd24f Mon Sep 17 00:00:00 2001 From: Benjamin Kane Date: Tue, 17 Oct 2023 07:49:07 -0600 Subject: [PATCH 10/36] Fix dataset deletion/recreation across processes (#3655) * try reload * mock deletion/recreation test * lint * pass original _create --- fiftyone/core/singletons.py | 8 +++++++- tests/unittests/synchronization_tests.py | 7 +++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/fiftyone/core/singletons.py b/fiftyone/core/singletons.py index 918e53a377..fb54621544 100644 --- a/fiftyone/core/singletons.py +++ b/fiftyone/core/singletons.py @@ -36,7 +36,13 @@ def __call__(cls, name=None, _create=True, *args, **kwargs): instance.__init__(name=name, _create=_create, *args, **kwargs) name = instance.name # `__init__` may have changed `name` else: - instance._update_last_loaded_at() + try: + instance._update_last_loaded_at() + except ValueError: + instance._deleted = True + return cls.__call__( + name=name, _create=_create, *args, **kwargs + ) cls._instances[name] = instance diff --git a/tests/unittests/synchronization_tests.py b/tests/unittests/synchronization_tests.py index e02c32f2bf..65850fc8de 100644 --- a/tests/unittests/synchronization_tests.py +++ b/tests/unittests/synchronization_tests.py @@ -30,6 +30,13 @@ def test_dataset_singleton(self): with self.assertRaises(ValueError): fo.Dataset("test_dataset") + dataset1.delete() + new_dataset1 = fo.Dataset("test_dataset") + + dataset1.__class__._instances["test_dataset"] = dataset1 + new_dataset1 = fo.load_dataset("test_dataset") + self.assertIsNot(dataset1, new_dataset1) + @drop_datasets def test_sample_singletons(self): """Tests that samples are singletons.""" From 224fa4e1d444974d3e3c8c4bda4c6d2cebcc48b2 Mon Sep 17 00:00:00 2001 From: Benjamin Kane Date: Tue, 17 Oct 2023 07:56:44 -0600 Subject: [PATCH 11/36] bump desktop (#3674) * bump desktop * syntax change --- package/desktop/setup.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package/desktop/setup.py b/package/desktop/setup.py index 5384e47c4f..b552a4aae1 100644 --- a/package/desktop/setup.py +++ b/package/desktop/setup.py @@ -16,7 +16,7 @@ import shutil -VERSION = "0.30.3" +VERSION = "0.31" def get_version(): diff --git a/setup.py b/setup.py index dcffcef187..b8d17d3557 100644 --- a/setup.py +++ b/setup.py @@ -113,7 +113,7 @@ def get_install_requirements(install_requires, choose_install_requires): return install_requires -EXTRAS_REQUIREMENTS = {"desktop": ["fiftyone-desktop>=0.30,<0.31"]} +EXTRAS_REQUIREMENTS = {"desktop": ["fiftyone-desktop~=0.31"]} with open("README.md", "r") as fh: From a5fa26d6d8de9eaac3fc1c314c0cabc22770c991 Mon Sep 17 00:00:00 2001 From: Benjamin Kane Date: Tue, 17 Oct 2023 08:12:58 -0600 Subject: [PATCH 12/36] Add polling to Colab session URL (#3645) * including polling param in colab session url * lint --- fiftyone/core/context.py | 1 + 1 file changed, 1 insertion(+) diff --git a/fiftyone/core/context.py b/fiftyone/core/context.py index d6761ed379..c02ed01f04 100644 --- a/fiftyone/core/context.py +++ b/fiftyone/core/context.py @@ -153,6 +153,7 @@ def get_url( from google.colab.output import eval_js _url = eval_js(f"google.colab.kernel.proxyPort({port})") + kwargs["polling"] = "true" elif _context == _DATABRICKS: _url = _get_databricks_proxy_url(port) kwargs["proxy"] = _get_databricks_proxy(port) From f7d143a04cc7525580c288168031fde4df5446d3 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Tue, 17 Oct 2023 10:46:55 -0700 Subject: [PATCH 13/36] add more info to operator upload file type --- .../plugins/SchemaIO/components/FileDrop.tsx | 9 ++- .../components/FileExplorerView/FileTable.tsx | 14 +--- .../plugins/SchemaIO/components/FileView.tsx | 74 ++++++++++++------- app/packages/utilities/src/index.ts | 13 ++++ fiftyone/operators/types.py | 28 ++++++- 5 files changed, 95 insertions(+), 43 deletions(-) diff --git a/app/packages/core/src/plugins/SchemaIO/components/FileDrop.tsx b/app/packages/core/src/plugins/SchemaIO/components/FileDrop.tsx index 2e8507a379..d4660bc56b 100644 --- a/app/packages/core/src/plugins/SchemaIO/components/FileDrop.tsx +++ b/app/packages/core/src/plugins/SchemaIO/components/FileDrop.tsx @@ -28,13 +28,20 @@ export default function FileDrop({ const [fileIds, setFileIds] = useState(new Set()); const fileInputRef = useRef(null); + const clear = () => { + setFiles([]); + setFileIds(new Set()); + }; + useEffect(() => { const updatedFileIds = new Set(); for (const file of files) { updatedFileIds.add(getFileId(file)); } - if (onChange) onChange(files); setFileIds(updatedFileIds); + if (onChange) { + onChange(files, clear); + } }, [files]); function addUniqueFiles(newFiles: FileList) { diff --git a/app/packages/core/src/plugins/SchemaIO/components/FileExplorerView/FileTable.tsx b/app/packages/core/src/plugins/SchemaIO/components/FileExplorerView/FileTable.tsx index d1547ee143..2f6c2cf0f4 100644 --- a/app/packages/core/src/plugins/SchemaIO/components/FileExplorerView/FileTable.tsx +++ b/app/packages/core/src/plugins/SchemaIO/components/FileExplorerView/FileTable.tsx @@ -14,6 +14,7 @@ import FolderIcon from "@mui/icons-material/Folder"; import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; import moment from "moment"; import { scrollable } from "@fiftyone/components"; +import { humanReadableBytes } from "@fiftyone/utilities"; const Wrapper = ({ children }) => ( ( ); -function humanReadableBytes(bytes: number): string { - if (!bytes) return ""; - - const units: string[] = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; - - if (bytes === 0) return "0 Byte"; - - const k = 1024; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + units[i]; -} - function FileTable({ chooseMode, files, diff --git a/app/packages/core/src/plugins/SchemaIO/components/FileView.tsx b/app/packages/core/src/plugins/SchemaIO/components/FileView.tsx index d96cbced90..f945c58283 100644 --- a/app/packages/core/src/plugins/SchemaIO/components/FileView.tsx +++ b/app/packages/core/src/plugins/SchemaIO/components/FileView.tsx @@ -1,43 +1,63 @@ -import { Box } from "@mui/material"; +import { Alert, Box, Typography } from "@mui/material"; import React, { useState } from "react"; import FileDrop from "./FileDrop"; import HeaderView from "./HeaderView"; -import TabsView from "./TabsView"; -import TextFieldView from "./TextFieldView"; import { getComponentProps } from "../utils"; +import { humanReadableBytes } from "@fiftyone/utilities"; export default function FileView(props) { const { onChange, path, schema, autoFocused } = props; const { view = {} } = schema; const { types } = view; - const [type, setType] = useState("file"); + const [type] = useState("file"); + const isObject = schema.type === "object"; + const maxSize = view.max_size || null; + const customMaxSizeMessage = view.max_size_error_message || null; + const [currentError, setCurrentError] = useState(null); + + const showError = (message) => { + setCurrentError({ message }); + }; return ( - { - setType(value); - onChange(path, ""); - }} - {...getComponentProps(props, "tabs")} - /> {type === "file" && ( { - if (files?.length === 0) return onChange(path, ""); + onChange={async (files, clear) => { + if (files?.length === 0) { + return onChange(path, null); + } const [file] = files; const { error, result } = await fileToBase64(file); - if (error) return; // todo: handle error + if (error) { + clear(); + // NOTE: error is a ProgressEvent + // there is no error message - so we print it to the console + const msg = "Error reading file"; + console.error(msg); + console.error(error); + showError(msg); + return; + } + if (maxSize && file.size > maxSize) { + clear(); + showError( + customMaxSizeMessage || + `File size must be less than ${humanReadableBytes(maxSize)}` + ); + return; + } + setCurrentError(null); + const obj = { + contents: result, + name: file.name, + type: file.type, + size: file.size, + last_modified: file.lastModified, + }; + if (isObject) return onChange(path, obj); onChange(path, result); }} types={types} @@ -46,12 +66,10 @@ export default function FileView(props) { {...getComponentProps(props, "fileDrop")} /> )} - {type === "url" && ( - + {currentError && ( + + {currentError.message} + )} diff --git a/app/packages/utilities/src/index.ts b/app/packages/utilities/src/index.ts index 3c5e335270..98550efdf3 100644 --- a/app/packages/utilities/src/index.ts +++ b/app/packages/utilities/src/index.ts @@ -716,3 +716,16 @@ export function pluralize( export const env = (): ImportMetaEnv => { return import.meta.env; }; + +export function humanReadableBytes(bytes: number): string { + if (!bytes) return ""; + + const units: string[] = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; + + if (bytes === 0) return "0 Byte"; + + const k = 1024; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + units[i]; +} diff --git a/fiftyone/operators/types.py b/fiftyone/operators/types.py index 8ffa8e30ea..515aefaa01 100644 --- a/fiftyone/operators/types.py +++ b/fiftyone/operators/types.py @@ -971,8 +971,34 @@ def __init__(self, **kwargs): super().__init__(**kwargs) +class UploadedFile(dict): + """Represents an uploaded file. + + Attributes: + name: the name of the file + type: the mime type of the file + size: the size of the file in bytes + contents: the base64 encoded contents of the file + last_modified: the last modified time of the file in ms since epoch + """ + + def __init__(self): + pass + + class FileView(View): - """Displays a file input.""" + """Displays a file input. + + .. note:: + + This view can be used on string or object properties. If used on a + string property, the value will be the file base64 encoded contents. + If used on an object the value will be a :class:`UploadedFile` object. + + Args: + max_size: the maximum size of the file in bytes + max_size_error_message: the error message to display if the file larger than the given max_size + """ def __init__(self, **kwargs): super().__init__(**kwargs) From 051aad8d1629a2b8c8645b62f510968014cd700c Mon Sep 17 00:00:00 2001 From: Benjamin Kane Date: Wed, 18 Oct 2023 13:33:30 -0600 Subject: [PATCH 14/36] Patches group slice fix (#3666) * select slice before patches * update saved view * unit test * add e2e test * fix e2e bug * improve python test * address PR comments --------- Co-authored-by: Lanny W --- .../groups/group-filter-toPatches.spec.ts | 97 +++++++++++++++++++ .../oss/specs/sidebar/sidebar-cifar.spec.ts | 4 +- fiftyone/server/mutation.py | 16 ++- fiftyone/server/view.py | 3 +- tests/unittests/server_tests.py | 56 +++++++++++ 5 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 e2e-pw/src/oss/specs/groups/group-filter-toPatches.spec.ts diff --git a/e2e-pw/src/oss/specs/groups/group-filter-toPatches.spec.ts b/e2e-pw/src/oss/specs/groups/group-filter-toPatches.spec.ts new file mode 100644 index 0000000000..5f0c1591fd --- /dev/null +++ b/e2e-pw/src/oss/specs/groups/group-filter-toPatches.spec.ts @@ -0,0 +1,97 @@ +import { test as base, expect } from "src/oss/fixtures"; +import { GridActionsRowPom } from "src/oss/poms/action-row/grid-actions-row"; +import { GridPom } from "src/oss/poms/grid"; +import { SidebarPom } from "src/oss/poms/sidebar"; +import { getUniqueDatasetNameWithPrefix } from "src/oss/utils"; + +const datasetName = getUniqueDatasetNameWithPrefix(`group-filter-toPatches`); +const test = base.extend<{ + grid: GridPom; + gridActionsRow: GridActionsRowPom; + sidebar: SidebarPom; +}>({ + grid: async ({ eventUtils, page }, use) => { + await use(new GridPom(page, eventUtils)); + }, + sidebar: async ({ page }, use) => { + await use(new SidebarPom(page)); + }, + gridActionsRow: async ({ eventUtils, page }, use) => { + await use(new GridActionsRowPom(page, eventUtils)); + }, +}); + +test.beforeAll(async ({ fiftyoneLoader }) => { + await fiftyoneLoader.executePythonCode(` + import fiftyone as fo + dataset = fo.Dataset("${datasetName}") + dataset.add_group_field("group", default="left") + dataset.persistent = True + group = fo.Group() + slices = ["left", "right"] + samples = [] + + for i in range(0, 10): + sample = fo.Sample( + filepath=f"{i}-first.png", + group=group.element(name=slices[i%2]), + predictions=fo.Detections( + detections = [ + fo.Detection( + label = "carrot", + confidence = 0.8, + ), + fo.Detection( + label = "not-carrot", + confidence = 0.25 + ) + ] + ) + ) + samples.append(sample) + dataset.add_samples(samples) + `); +}); + +test.beforeEach(async ({ page, fiftyoneLoader }) => { + await fiftyoneLoader.waitUntilGridVisible(page, datasetName); +}); + +test(`group dataset with filters converts toPatches correctly`, async ({ + page, + grid, + gridActionsRow, + sidebar, + eventUtils, +}) => { + await grid.assert.isEntryCountTextEqualTo("5 groups with slice"); + + // apply a sidebar filter + const entryExpandPromise = + eventUtils.getEventReceivedPromiseForPredicate("animation-onRest"); + await sidebar.clickFieldDropdown("predictions"); + await entryExpandPromise; + + await sidebar.waitForElement("checkbox-carrot"); + await sidebar.applyLabelFromList( + "predictions.detections.label", + ["carrot"], + "select-detections-with-label" + ); + + // convert to patches + await grid.actionsRow.toggleToClipsOrPatches(); + const toPatchesRefresh = grid.getWaitForGridRefreshPromise(); + await gridActionsRow.clickToPatchesByLabelField("predictions"); + await toPatchesRefresh; + + // verify result: + await grid.assert.isEntryCountTextEqualTo("5 patches"); + + // not-carrot should not be in the sidebar filter anymore + const expandPromise = + eventUtils.getEventReceivedPromiseForPredicate("animation-onRest"); + await sidebar.clickFieldDropdown("predictions"); + await expandPromise; + expect(await page.getByTestId("checkbox-not-carrot").count()).toEqual(0); +}); diff --git a/e2e-pw/src/oss/specs/sidebar/sidebar-cifar.spec.ts b/e2e-pw/src/oss/specs/sidebar/sidebar-cifar.spec.ts index 9e4c0c9090..964ecb3f4d 100644 --- a/e2e-pw/src/oss/specs/sidebar/sidebar-cifar.spec.ts +++ b/e2e-pw/src/oss/specs/sidebar/sidebar-cifar.spec.ts @@ -6,8 +6,8 @@ import { getUniqueDatasetNameWithPrefix } from "src/oss/utils"; const datasetName = getUniqueDatasetNameWithPrefix("classification-5"); const test = base.extend<{ sidebar: SidebarPom; grid: GridPom }>({ - sidebar: async ({ page }, use) => { - await use(new SidebarPom(page)); + sidebar: async ({ page, eventUtils }, use) => { + await use(new SidebarPom(page, eventUtils)); }, grid: async ({ page, eventUtils }, use) => { await use(new GridPom(page, eventUtils)); diff --git a/fiftyone/server/mutation.py b/fiftyone/server/mutation.py index 201e820e41..f3fa82fcbd 100644 --- a/fiftyone/server/mutation.py +++ b/fiftyone/server/mutation.py @@ -31,6 +31,7 @@ SidebarGroup, SavedView, ) +from fiftyone.server.aggregations import GroupElementFilter, SampleFilter from fiftyone.server.scalars import BSON, BSONArray, JSON, JSONArray from fiftyone.server.view import get_view @@ -187,6 +188,13 @@ async def set_view( stages=view if view else None, filters=form.filters if form else None, extended_stages=form.extended if form else None, + sample_filter=SampleFilter( + group=GroupElementFilter( + slice=form.slice, slices=[form.slice] + ) + ) + if form.slice + else None, ) result_view = _build_result_view(result_view, form) @@ -279,6 +287,11 @@ async def create_saved_view( stages=view_stages if view_stages else None, filters=form.filters if form else None, extended_stages=form.extended if form else None, + sample_filter=SampleFilter( + group=GroupElementFilter(slice=form.slice, slices=[form.slice]) + ) + if form.slice + else None, ) result_view = _build_result_view(dataset_view, form) @@ -473,9 +486,6 @@ async def search_select_fields( def _build_result_view(view, form): - if form.slice: - view = view.select_group_slices([form.slice]) - if form.sample_ids: view = fov.make_optimized_select_view(view, form.sample_ids) diff --git a/fiftyone/server/view.py b/fiftyone/server/view.py index 4e814b8eed..658068d6d9 100644 --- a/fiftyone/server/view.py +++ b/fiftyone/server/view.py @@ -126,7 +126,8 @@ def get_view( ) if sample_filter.group.slices: view = view.select_group_slices( - sample_filter.group.slices, _force_mixed=True + sample_filter.group.slices, + _force_mixed=len(sample_filter.group.slices) > 1, ) elif sample_filter.id: diff --git a/tests/unittests/server_tests.py b/tests/unittests/server_tests.py index 3142a45bca..e7b8dac3c9 100644 --- a/tests/unittests/server_tests.py +++ b/tests/unittests/server_tests.py @@ -803,6 +803,62 @@ def test_disjoint_groups(self): ) self.assertEqual(second_view.first().id, second.id) + @drop_datasets + def test_get_view_captures_all_parameters(self): + dataset = fod.Dataset("test") + dataset.add_group_field("group", default="first") + group_one = fo.Group() + group_two = fo.Group() + sample_one = fos.Sample( + filepath="image1.png", + predictions=fol.Detections( + detections=[ + fol.Detection( + label="carrot", confidence=0.25, tags=["one", "two"] + ), + fol.Detection( + label="not_carrot", confidence=0.75, tags=["two"] + ), + ] + ), + group=group_one.element(name="first"), + ) + sample_two = fos.Sample( + filepath="image2.png", + predictions=fol.Detections( + detections=[ + fol.Detection( + label="carrot", confidence=0.25, tags=["one", "two"] + ), + fol.Detection( + label="not_carrot", confidence=0.75, tags=["two"] + ), + ] + ), + group=group_two.element(name="second"), + ) + dataset.add_sample(sample_one) + dataset.add_sample(sample_two) + + view = fosv.get_view( + dataset.name, + sample_filter=fosv.SampleFilter( + group=fosv.GroupElementFilter( + slice="first", + id=dataset.first().group.id, + slices=["first", "second"], + ) + ), + filters={ + "predictions.detections.label": { + "values": ["carrot"], + "exclude": False, + "isMatching": False, + } + }, + ) + self.assertEqual(len(view), 1) + class AysncServerViewTests(unittest.IsolatedAsyncioTestCase): @drop_datasets From 267180d2afb3ddfc4adb13c13c1a5352bd56ee83 Mon Sep 17 00:00:00 2001 From: brimoor Date: Wed, 18 Oct 2023 21:46:24 -0400 Subject: [PATCH 15/36] fixing docs typo --- fiftyone/core/collections.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fiftyone/core/collections.py b/fiftyone/core/collections.py index bfef83a5cf..d453b69d68 100644 --- a/fiftyone/core/collections.py +++ b/fiftyone/core/collections.py @@ -2115,8 +2115,8 @@ def set_label_values( .. note:: This method is appropriate when you have the IDs of the labels you - wish to modify. See :meth`set_values` and :meth:`set_field` if your - updates are not keyed by label ID. + wish to modify. See :meth:`set_values` and :meth:`set_field` if + your updates are not keyed by label ID. Examples:: From fecf4cd67fbeed04bb2794dd864df696065576ba Mon Sep 17 00:00:00 2001 From: brimoor Date: Wed, 18 Oct 2023 21:51:20 -0400 Subject: [PATCH 16/36] remove identity layer error --- fiftyone/utils/torch.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/fiftyone/utils/torch.py b/fiftyone/utils/torch.py index 44b7a96b10..75aaa51eda 100644 --- a/fiftyone/utils/torch.py +++ b/fiftyone/utils/torch.py @@ -1031,11 +1031,6 @@ def _setup(self, model, layer_name): if _layer is None: raise ValueError("No layer found with name %s" % layer_name) - elif isinstance(_layer, torch.nn.Identity): - raise ValueError( - "Layer '%s' is an Identity layer. Use previous layer." - % layer_name - ) _layer.register_forward_hook(self) From cc81dfe0c9d408bc894427157cb0dd389089b30a Mon Sep 17 00:00:00 2001 From: brimoor Date: Wed, 18 Oct 2023 21:56:41 -0400 Subject: [PATCH 17/36] fixing set indexing --- fiftyone/utils/cvat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fiftyone/utils/cvat.py b/fiftyone/utils/cvat.py index ed4cd5e7fe..a6580439d6 100644 --- a/fiftyone/utils/cvat.py +++ b/fiftyone/utils/cvat.py @@ -222,7 +222,7 @@ def import_annotations( "Ignoring annotations for %d filepaths (eg %s) that do not " "appear in the input collection", len(new_filepaths), - new_filepaths[0], + next(iter(new_filepaths)), ) if dataset.media_type == fom.VIDEO: From 9b590633f6ea188a4ded2728ce33f1c3e554dbc2 Mon Sep 17 00:00:00 2001 From: brimoor Date: Wed, 18 Oct 2023 23:37:27 -0400 Subject: [PATCH 18/36] don't include disabled operators --- fiftyone/factory/repos/delegated_operation.py | 2 +- fiftyone/factory/repos/delegated_operation_doc.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/fiftyone/factory/repos/delegated_operation.py b/fiftyone/factory/repos/delegated_operation.py index a10e8c2533..aee6c58d35 100644 --- a/fiftyone/factory/repos/delegated_operation.py +++ b/fiftyone/factory/repos/delegated_operation.py @@ -281,7 +281,7 @@ def list_operations( if paging.limit: docs = docs.limit(paging.limit) - registry = OperatorRegistry(enabled="all") + registry = OperatorRegistry() return [ DelegatedOperationDocument().from_pymongo(doc, registry=registry) for doc in docs diff --git a/fiftyone/factory/repos/delegated_operation_doc.py b/fiftyone/factory/repos/delegated_operation_doc.py index 569da5cffd..e723800a97 100644 --- a/fiftyone/factory/repos/delegated_operation_doc.py +++ b/fiftyone/factory/repos/delegated_operation_doc.py @@ -92,7 +92,7 @@ def from_pymongo(self, doc: dict, registry: OperatorRegistry = None): # generated fields: try: if registry is None: - registry = OperatorRegistry(enabled="all") + registry = OperatorRegistry() if registry.operator_exists(self.operator) is False: raise ValueError( From 3dbdadd56d73196172f30fa90110959dc43b0e8b Mon Sep 17 00:00:00 2001 From: brimoor Date: Wed, 18 Oct 2023 23:37:59 -0400 Subject: [PATCH 19/36] add operator_exists() method --- fiftyone/core/cli.py | 2 +- fiftyone/operators/__init__.py | 7 ++++++- fiftyone/operators/registry.py | 24 ++++++++++++++++++++++-- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/fiftyone/core/cli.py b/fiftyone/core/cli.py index 44b32b16e7..cb6ca365a1 100644 --- a/fiftyone/core/cli.py +++ b/fiftyone/core/cli.py @@ -2736,7 +2736,7 @@ def execute(parser, args): def _print_operator_info(operator_uri): - operator = foo.get_operator(operator_uri) + operator = foo.get_operator(operator_uri, enabled="all") d = operator.config.to_json() _print_dict_as_table(d) diff --git a/fiftyone/operators/__init__.py b/fiftyone/operators/__init__.py index 08f5f85a2f..bda9f0581e 100644 --- a/fiftyone/operators/__init__.py +++ b/fiftyone/operators/__init__.py @@ -6,7 +6,12 @@ | """ from .operator import Operator, OperatorConfig -from .registry import OperatorRegistry, get_operator, list_operators +from .registry import ( + OperatorRegistry, + get_operator, + list_operators, + operator_exists, +) from .executor import execute_operator, execute_or_delegate_operator # This enables Sphinx refs to directly use paths imported here diff --git a/fiftyone/operators/registry.py b/fiftyone/operators/registry.py index 8824237091..19d5841cf9 100644 --- a/fiftyone/operators/registry.py +++ b/fiftyone/operators/registry.py @@ -9,16 +9,21 @@ import fiftyone.plugins.context as fopc -def get_operator(operator_uri): +def get_operator(operator_uri, enabled=True): """Gets the operator with the given URI. Args: operator_uri: the operator URI + enabled (True): whether to include only enabled operators (True) or + only disabled operators (False) or all operators ("all") Returns: an :class:`fiftyone.operators.Operator` + + Raises: + ValueError: if the operator is not found """ - registry = OperatorRegistry(enabled="all") + registry = OperatorRegistry(enabled=enabled) operator = registry.get_operator(operator_uri) if operator is None: raise ValueError(f"Operator '{operator_uri}' not found") @@ -40,6 +45,21 @@ def list_operators(enabled=True): return registry.list_operators(include_builtin=enabled != False) +def operator_exists(operator_uri, enabled=True): + """Checks if the given operator exists. + + Args: + operator_uri: the operator URI + enabled (True): whether to include only enabled operators (True) or + only disabled operators (False) or all operators ("all") + + Returns: + True/False + """ + registry = OperatorRegistry(enabled=enabled) + return registry.operator_exists(operator_uri) + + class OperatorRegistry(object): """Operator registry. From 3713dd64639f40b3cc93433a94d2f5ecb238f869 Mon Sep 17 00:00:00 2001 From: imanjra Date: Wed, 18 Oct 2023 13:25:25 -0400 Subject: [PATCH 20/36] operator number type enhancements --- .../src/plugins/OperatorIO/utils/index.ts | 7 +++++++ .../SchemaIO/components/SliderView.tsx | 4 ++++ .../SchemaIO/components/TextFieldView.tsx | 7 +++++-- app/packages/operators/src/validation.ts | 18 ++++++++++++++++++ fiftyone/operators/executor.py | 19 +++++++++++++++---- fiftyone/operators/types.py | 16 ++++++++++++---- 6 files changed, 61 insertions(+), 10 deletions(-) diff --git a/app/packages/core/src/plugins/OperatorIO/utils/index.ts b/app/packages/core/src/plugins/OperatorIO/utils/index.ts index df5423eb48..a52c66ef0e 100644 --- a/app/packages/core/src/plugins/OperatorIO/utils/index.ts +++ b/app/packages/core/src/plugins/OperatorIO/utils/index.ts @@ -105,6 +105,13 @@ function getSchema(property, options = {}) { const computedOptions = { ...options, readOnly: schema.view.readOnly }; + if (typeName === "Number") { + const { min, max, float } = property.type; + schema.min = min; + schema.max = max; + schema.multipleOf = float ? 0.01 : 1; + } + if (typeName === "Object") { schema.properties = getPropertiesSchema(property, computedOptions); } diff --git a/app/packages/core/src/plugins/SchemaIO/components/SliderView.tsx b/app/packages/core/src/plugins/SchemaIO/components/SliderView.tsx index 9867f98902..a899e30012 100644 --- a/app/packages/core/src/plugins/SchemaIO/components/SliderView.tsx +++ b/app/packages/core/src/plugins/SchemaIO/components/SliderView.tsx @@ -7,6 +7,7 @@ export default function SliderView(props) { const { data, onChange, path, schema } = props; const sliderRef = useRef(null); const focus = autoFocus(props); + const { min = 0, max = 100, multipleOf = 1 } = schema; useEffect(() => { if (sliderRef.current && focus) { @@ -17,6 +18,9 @@ export default function SliderView(props) { return ( @@ -22,7 +24,8 @@ export default function TextFieldView(props) { const value = e.target.value; onChange(path, type === "number" ? parseFloat(value) : value); }} - {...getComponentProps(props, "field")} + inputProps={{ min, max, step: multipleOf, ...inputProps }} + {...fieldProps} /> ); diff --git a/app/packages/operators/src/validation.ts b/app/packages/operators/src/validation.ts index ee32e4e7c3..58021cfdc2 100644 --- a/app/packages/operators/src/validation.ts +++ b/app/packages/operators/src/validation.ts @@ -132,6 +132,24 @@ export class ValidationContext { new ValidationError("Invalid value type", property, path) ); } + + if (expectedType === "number") { + const { min, max } = property.type; + if (isNumber(min) && value < min) { + return this.addError( + new ValidationError( + `Value must be greater than ${min}`, + property, + path + ) + ); + } + if (isNumber(max) && value > max) { + return this.addError( + new ValidationError(`Value must be less than ${max}`, property, path) + ); + } + } } } diff --git a/fiftyone/operators/executor.py b/fiftyone/operators/executor.py index 2e98ad7aac..22bff21e29 100644 --- a/fiftyone/operators/executor.py +++ b/fiftyone/operators/executor.py @@ -732,10 +732,21 @@ def validate_primitive(self, path, property, value): if type_name == "String" and value_type != str: return ValidationError("Invalid value type", property, path) - if type_name == "Number" and ( - value_type != int and value_type != float - ): - return ValidationError("Invalid value type", property, path) + if type_name == "Number": + min = property.type.min + min_type = type(min) + max = property.type.max + max_type = type(max) + if value_type != int and value_type != float: + return ValidationError("Invalid value type", property, path) + if (min_type == int or min_type == float) and value < min: + return ValidationError( + f"Value must be greater than {min}", property, path + ) + if (max_type == int or max_type == float) and value > max: + return ValidationError( + f"Value must be less than {max}", property, path + ) if type_name == "Boolean" and value_type != bool: return ValidationError("Invalid value type", property, path) diff --git a/fiftyone/operators/types.py b/fiftyone/operators/types.py index 515aefaa01..1a9caf774a 100644 --- a/fiftyone/operators/types.py +++ b/fiftyone/operators/types.py @@ -120,11 +120,13 @@ def bool(self, name, **kwargs): """ return self.define_property(name, Boolean(), **kwargs) - def int(self, name, **kwargs): + def int(self, name, min=None, max=None, **kwargs): """Defines a property on the object that is an integer. Args: name: the name of the property + min: minimum value of the property + max: maximum value of the property label (None): the label of the property description (None): the description of the property view (None): the :class:`View` of the property @@ -132,13 +134,17 @@ def int(self, name, **kwargs): Returns: a :class:`Property` """ - return self.define_property(name, Number(int=True), **kwargs) + return self.define_property( + name, Number(int=True, min=min, max=max), **kwargs + ) - def float(self, name, **kwargs): + def float(self, name, min=None, max=None, **kwargs): """Defines a property on the object that is a float. Args: name: the name of the property + min: minimum value of the property + max: maximum value of the property label (None): the label of the property description (None): the description of the property view (None): the :class:`View` of the property @@ -146,7 +152,9 @@ def float(self, name, **kwargs): Returns: a :class:`Property` """ - return self.define_property(name, Number(float=True), **kwargs) + return self.define_property( + name, Number(float=True, min=min, max=max), **kwargs + ) def enum(self, name, values, **kwargs): """Defines a property on the object that is an enum. From d48a69b333d5ca7c33663ba602a8b70c567e4762 Mon Sep 17 00:00:00 2001 From: imanjra Date: Wed, 18 Oct 2023 14:26:55 -0400 Subject: [PATCH 21/36] primitive types validation enhancements --- app/packages/operators/src/types.ts | 17 ++++++++++++++--- app/packages/operators/src/validation.ts | 13 ++++++++++++- fiftyone/operators/executor.py | 12 +++++++++++- fiftyone/operators/types.py | 23 ++++++++++++++++++----- 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/app/packages/operators/src/types.ts b/app/packages/operators/src/types.ts index 712fede359..a0a157b0b8 100644 --- a/app/packages/operators/src/types.ts +++ b/app/packages/operators/src/types.ts @@ -237,10 +237,21 @@ export class Property { * Operator type for representing a string value for operator input/output. */ class OperatorString extends BaseType { + allowEmpty?: boolean; + + /** + * Construct operator type for string values + * @param options options for defining constraints on a string value + * @param options.allowEmpty allow an empty string value + * number + */ + constructor(options: { allowEmpty?: boolean } = {}) { + super(); + this.allowEmpty = options.allowEmpty; + } + static fromJSON(json: any) { - const Type = this; - const type = new Type(); - return type; + return new OperatorString({ allowEmpty: json.allow_empty }); } } export { OperatorString as String }; diff --git a/app/packages/operators/src/validation.ts b/app/packages/operators/src/validation.ts index 58021cfdc2..8cda7f97f0 100644 --- a/app/packages/operators/src/validation.ts +++ b/app/packages/operators/src/validation.ts @@ -56,7 +56,7 @@ export class ValidationContext { ), true ); - } else if (property.required && valueIsNullish) { + } else if (!existsOrNonRequired(property, value)) { this.addError(new ValidationError("Required property", property, path)); } else if (type instanceof Enum && !valueIsNullish) { this.validateEnum(path, property, value); @@ -153,6 +153,17 @@ export class ValidationContext { } } +function existsOrNonRequired(property, value) { + const expectedType = getOperatorTypeName(property.type); + if (expectedType === "string" && !property.type.allowEmpty && value === "") { + return false; + } + if (expectedType === "number" && isNaN(value)) { + return false; + } + return !property.required || !isNullish(value); +} + function getPath(prefix, path) { return prefix ? prefix + "." + path : path; } diff --git a/fiftyone/operators/executor.py b/fiftyone/operators/executor.py index 22bff21e29..49afb4a38a 100644 --- a/fiftyone/operators/executor.py +++ b/fiftyone/operators/executor.py @@ -678,7 +678,7 @@ def validate_property(self, path, property, value): property.error_message, property, path, True ) - if property.required and value is None: + if not self.exists_or_non_required(property, value): return ValidationError("Required property", property, path) if value is not None: @@ -750,3 +750,13 @@ def validate_primitive(self, path, property, value): if type_name == "Boolean" and value_type != bool: return ValidationError("Invalid value type", property, path) + + def exists_or_non_required(self, property, value): + type_name = property.type.__class__.__name__ + + if type_name == "String": + allow_empty = property.type.allow_empty + if not allow_empty and value == "": + return False + + return not property.required or value is not None diff --git a/fiftyone/operators/types.py b/fiftyone/operators/types.py index 1a9caf774a..315b2f1397 100644 --- a/fiftyone/operators/types.py +++ b/fiftyone/operators/types.py @@ -92,7 +92,7 @@ def define_property(self, name, type, **kwargs): self.add_property(name, property) return property - def str(self, name, **kwargs): + def str(self, name, allow_empty=False, **kwargs): """Defines a property on the object that is a string. Args: @@ -104,7 +104,9 @@ def str(self, name, **kwargs): Returns: a :class:`Property` """ - return self.define_property(name, String(), **kwargs) + return self.define_property( + name, String(allow_empty=allow_empty), **kwargs + ) def bool(self, name, **kwargs): """Defines a property on the object that is a boolean. @@ -310,6 +312,7 @@ def __init__(self, type, **kwargs): self.invalid = kwargs.get("invalid", False) self.default = kwargs.get("default", None) self.required = kwargs.get("required", False) + # todo: deprecate and remove self.choices = kwargs.get("choices", None) self.error_message = kwargs.get("error_message", "Invalid property") self.view = kwargs.get("view", None) @@ -327,10 +330,20 @@ def to_json(self): class String(BaseType): - """Represents a string.""" + """Represents a string. - def __init__(self): - pass + Args: + allow_empty (False): allow an empty string value + """ + + def __init__(self, allow_empty=False): + self.allow_empty = allow_empty + + def to_json(self): + return { + **super().to_json(), + "allow_empty": self.allow_empty, + } class Boolean(BaseType): From 126a0ffd952b32595350dfccd570fb309e7ec895 Mon Sep 17 00:00:00 2001 From: brimoor Date: Thu, 19 Oct 2023 12:38:21 -0400 Subject: [PATCH 22/36] fixing docs warning --- docs/source/teams/installation.rst | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/source/teams/installation.rst b/docs/source/teams/installation.rst index 28aa927f0c..90f8b1b782 100644 --- a/docs/source/teams/installation.rst +++ b/docs/source/teams/installation.rst @@ -157,17 +157,16 @@ You should then see snippets in the ``pyproject.toml`` file like the following Cloud credentials ----------------- - .. _teams-cors: Cross-Origin Resource Sharing (CORS) -_________ - -If your datasets will include cloud-backed :ref:`point-cloud files ` -or :ref:`segmentation maps `, you may also need to configure -cross-origin resource sharing (CORS) for your cloud buckets. Details are provided below -for each cloud platform. +____________________________________ +If your datasets include cloud-backed +:ref:`point clouds ` or +:ref:`segmentation maps `, you may need to configure +cross-origin resource sharing (CORS) for your cloud buckets. Details are +provided below for each cloud platform. .. _teams-amazon-s3: From 3f265918a289b871af0be8d447a910f8f240324a Mon Sep 17 00:00:00 2001 From: imanjra Date: Fri, 13 Oct 2023 11:16:52 -0400 Subject: [PATCH 23/36] fix placement icon showing fallback icon --- app/packages/operators/src/OperatorPlacements.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/packages/operators/src/OperatorPlacements.tsx b/app/packages/operators/src/OperatorPlacements.tsx index 23807b46fe..236132cfc7 100644 --- a/app/packages/operators/src/OperatorPlacements.tsx +++ b/app/packages/operators/src/OperatorPlacements.tsx @@ -50,6 +50,7 @@ function ButtonPlacement(props: OperatorPlacementProps) { const { label } = view; const { icon, darkIcon, lightIcon, prompt = true } = view?.options || {}; const { execute } = useOperatorExecutor(uri); + const canExecute = operator?.config?.canExecute; const showIcon = isPrimitiveString(icon) || @@ -64,6 +65,7 @@ function ButtonPlacement(props: OperatorPlacementProps) { darkIcon={darkIcon} lightIcon={lightIcon} Fallback={Extension} + canExecute={canExecute} /> ); From e4847a2724640473a28c372723d5dafcfb0581a6 Mon Sep 17 00:00:00 2001 From: imanjra Date: Tue, 17 Oct 2023 14:21:38 -0400 Subject: [PATCH 24/36] include dataset name when resolving operators list --- .../operators/src/built-in-operators.ts | 4 ++-- app/packages/operators/src/operators.ts | 8 ++++++-- app/packages/operators/src/state.ts | 8 +++++++- app/packages/plugins/src/index.ts | 17 +++++++++++++++-- fiftyone/operators/permissions.py | 6 ++++-- fiftyone/operators/server.py | 6 ++++-- 6 files changed, 38 insertions(+), 11 deletions(-) diff --git a/app/packages/operators/src/built-in-operators.ts b/app/packages/operators/src/built-in-operators.ts index ecf9c5cd98..d8b990a746 100644 --- a/app/packages/operators/src/built-in-operators.ts +++ b/app/packages/operators/src/built-in-operators.ts @@ -780,9 +780,9 @@ export function registerBuiltInOperators() { } } -export async function loadOperators() { +export async function loadOperators(datasetName: string) { registerBuiltInOperators(); - await loadOperatorsFromServer(); + await loadOperatorsFromServer(datasetName); } function getLayout(layout) { diff --git a/app/packages/operators/src/operators.ts b/app/packages/operators/src/operators.ts index def820786b..b9b380dabd 100644 --- a/app/packages/operators/src/operators.ts +++ b/app/packages/operators/src/operators.ts @@ -294,10 +294,14 @@ export function _registerBuiltInOperator(OperatorType: typeof Operator) { localRegistry.register(operator); } -export async function loadOperatorsFromServer() { +export async function loadOperatorsFromServer(datasetName: string) { initializationErrors = []; try { - const { operators, errors } = await getFetchFunction()("GET", "/operators"); + const { operators, errors } = await getFetchFunction()( + "POST", + "/operators", + { dataset_name: datasetName } + ); const operatorInstances = operators.map((d: any) => Operator.fromRemoteJSON(d) ); diff --git a/app/packages/operators/src/state.ts b/app/packages/operators/src/state.ts index 8769e3c119..28d20bf062 100644 --- a/app/packages/operators/src/state.ts +++ b/app/packages/operators/src/state.ts @@ -389,9 +389,15 @@ export function filterChoicesByQuery(query, all) { }); } +export const availableOperatorsRefreshCount = atom({ + key: "availableOperatorsRefreshCount", + default: 0, +}); + export const availableOperators = selector({ key: "availableOperators", - get: () => { + get: ({ get }) => { + get(availableOperatorsRefreshCount); // triggers force refresh manually return listLocalAndRemoteOperators().allOperators.map((operator) => { return { label: operator.label, diff --git a/app/packages/plugins/src/index.ts b/app/packages/plugins/src/index.ts index 6ecbf4ba3e..d520260ad7 100644 --- a/app/packages/plugins/src/index.ts +++ b/app/packages/plugins/src/index.ts @@ -7,6 +7,7 @@ import React, { FunctionComponent, useEffect, useMemo, useState } from "react"; import * as recoil from "recoil"; import { wrapCustomComponent } from "./components"; import "./externalize"; +import { availableOperatorsRefreshCount } from "@fiftyone/operators/src/state"; declare global { interface Window { @@ -93,9 +94,7 @@ class PluginDefinition { } } -let _settings = null; export async function loadPlugins() { - await foo.loadOperators(); const plugins = await fetchPluginsMetadata(); for (const plugin of plugins) { usingRegistry().registerPluginDefinition(plugin); @@ -144,6 +143,11 @@ async function loadScript(name, url) { */ export function usePlugins() { const [state, setState] = useState("loading"); + const datasetName = recoil.useRecoilValue(fos.datasetName); + const setAvailableOperatorsRefreshCount = recoil.useSetRecoilState( + availableOperatorsRefreshCount + ); + useEffect(() => { loadPlugins() .catch(() => { @@ -154,6 +158,15 @@ export function usePlugins() { }); }, []); + useEffect(() => { + if (fou.isPrimitiveString(datasetName)) { + foo.loadOperators(datasetName).then(() => { + // trigger force refresh + setAvailableOperatorsRefreshCount((count) => count + 1); + }); + } + }, [datasetName]); + return { isLoading: state === "loading", hasError: state === "error", diff --git a/fiftyone/operators/permissions.py b/fiftyone/operators/permissions.py index 0ee687eb88..f80db1d881 100644 --- a/fiftyone/operators/permissions.py +++ b/fiftyone/operators/permissions.py @@ -29,9 +29,11 @@ def can_execute(self, operator_uri): return self.managed_operators.has_operator(operator_uri) @classmethod - async def from_list_request(cls, request): + async def from_list_request(cls, request, dataset_ids=None): return PermissionedOperatorRegistry( - await ManagedOperators.for_request(request), + await ManagedOperators.for_request( + request, dataset_ids=dataset_ids + ), ) @classmethod diff --git a/fiftyone/operators/server.py b/fiftyone/operators/server.py index 4d99030ed5..247ff8aa4c 100644 --- a/fiftyone/operators/server.py +++ b/fiftyone/operators/server.py @@ -25,9 +25,11 @@ class ListOperators(HTTPEndpoint): @route - async def get(self, request: Request, data: dict): + async def post(self, request: Request, data: dict): + dataset_name = data.get("dataset_name", None) + dataset_ids = [dataset_name] registry = await PermissionedOperatorRegistry.from_list_request( - request + request, dataset_ids=dataset_ids ) ctx = ExecutionContext() operators_as_json = [ From a90e836c62eeb03f0d4846c8dac72763a96d5a00 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Thu, 19 Oct 2023 09:26:57 -0700 Subject: [PATCH 25/36] strip b64 prefix from uploaded files --- .../core/src/plugins/SchemaIO/components/FileView.tsx | 9 +++++++-- fiftyone/operators/types.py | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/packages/core/src/plugins/SchemaIO/components/FileView.tsx b/app/packages/core/src/plugins/SchemaIO/components/FileView.tsx index f945c58283..55011ca034 100644 --- a/app/packages/core/src/plugins/SchemaIO/components/FileView.tsx +++ b/app/packages/core/src/plugins/SchemaIO/components/FileView.tsx @@ -50,15 +50,16 @@ export default function FileView(props) { return; } setCurrentError(null); + const resultStripped = stripBase64Prefix(result); const obj = { - contents: result, + content: resultStripped, name: file.name, type: file.type, size: file.size, last_modified: file.lastModified, }; if (isObject) return onChange(path, obj); - onChange(path, result); + onChange(path, resultStripped); }} types={types} autoFocused={autoFocused} @@ -86,3 +87,7 @@ function fileToBase64( fileReader.onerror = (error) => resolve({ error }); }); } + +function stripBase64Prefix(data: string): string { + return data.slice(data.indexOf(",") + 1); +} diff --git a/fiftyone/operators/types.py b/fiftyone/operators/types.py index 315b2f1397..c00ea9cdc7 100644 --- a/fiftyone/operators/types.py +++ b/fiftyone/operators/types.py @@ -999,7 +999,7 @@ class UploadedFile(dict): name: the name of the file type: the mime type of the file size: the size of the file in bytes - contents: the base64 encoded contents of the file + content: the base64 encoded contents of the file last_modified: the last modified time of the file in ms since epoch """ From c111706383d0aa812a9f3dc0577a7087d32ffdd3 Mon Sep 17 00:00:00 2001 From: Benjamin Kane Date: Thu, 19 Oct 2023 11:08:54 -0600 Subject: [PATCH 26/36] Fix browser cache issues when upgrading (#3683) * always return index.html * no-store * linting --- fiftyone/server/app.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/fiftyone/server/app.py b/fiftyone/server/app.py index 8af626b007..36af875faf 100644 --- a/fiftyone/server/app.py +++ b/fiftyone/server/app.py @@ -11,6 +11,7 @@ import eta.core.utils as etau from starlette.applications import Starlette +from starlette.datastructures import Headers from starlette.middleware import Middleware from starlette.middleware.base import ( BaseHTTPMiddleware, @@ -18,9 +19,9 @@ ) from starlette.middleware.cors import CORSMiddleware from starlette.requests import Request -from starlette.responses import Response +from starlette.responses import FileResponse, Response from starlette.routing import Mount, Route -from starlette.staticfiles import StaticFiles +from starlette.staticfiles import NotModifiedResponse, PathLike, StaticFiles from starlette.types import Scope import strawberry as gql @@ -38,6 +39,29 @@ class Static(StaticFiles): + def file_response( + self, + full_path: PathLike, + stat_result: os.stat_result, + scope: Scope, + status_code: int = 200, + ) -> Response: + method = scope["method"] + request_headers = Headers(scope=scope) + + response = FileResponse( + full_path, + status_code=status_code, + stat_result=stat_result, + method=method, + ) + if response.path.endswith("index.html"): + response.headers["cache-control"] = "no-store" + elif self.is_not_modified(response.headers, request_headers): + return NotModifiedResponse(response.headers) + + return response + async def get_response(self, path: str, scope: Scope) -> Response: response = await super().get_response(path, scope) From b53c219be26bfabdd1c241fbbd15f89672c17207 Mon Sep 17 00:00:00 2001 From: imanjra Date: Thu, 19 Oct 2023 13:54:42 -0400 Subject: [PATCH 27/36] fix require validation false positive --- app/packages/operators/src/validation.ts | 3 ++- fiftyone/operators/executor.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/packages/operators/src/validation.ts b/app/packages/operators/src/validation.ts index 8cda7f97f0..eca72f97c6 100644 --- a/app/packages/operators/src/validation.ts +++ b/app/packages/operators/src/validation.ts @@ -154,6 +154,7 @@ export class ValidationContext { } function existsOrNonRequired(property, value) { + if (!property.required) return true; const expectedType = getOperatorTypeName(property.type); if (expectedType === "string" && !property.type.allowEmpty && value === "") { return false; @@ -161,7 +162,7 @@ function existsOrNonRequired(property, value) { if (expectedType === "number" && isNaN(value)) { return false; } - return !property.required || !isNullish(value); + return !isNullish(value); } function getPath(prefix, path) { diff --git a/fiftyone/operators/executor.py b/fiftyone/operators/executor.py index 49afb4a38a..e70cabc0df 100644 --- a/fiftyone/operators/executor.py +++ b/fiftyone/operators/executor.py @@ -752,6 +752,9 @@ def validate_primitive(self, path, property, value): return ValidationError("Invalid value type", property, path) def exists_or_non_required(self, property, value): + if not property.required: + return True + type_name = property.type.__class__.__name__ if type_name == "String": @@ -759,4 +762,4 @@ def exists_or_non_required(self, property, value): if not allow_empty and value == "": return False - return not property.required or value is not None + return value is not None From f943f1b15eec271738262e2d3fe194ceedefea8b Mon Sep 17 00:00:00 2001 From: Kacey Date: Thu, 19 Oct 2023 12:07:48 -0700 Subject: [PATCH 28/36] Fix plugin cache check (#3676) (#3700) * check top level dir and add test * cleanup * remove glob and refactor tests * cleanup * add test for renaming and add wait * add test for renaming and add sleep --- fiftyone/operators/decorators.py | 8 +- tests/unittests/operators/decorators_tests.py | 119 +++++++++++++++--- 2 files changed, 102 insertions(+), 25 deletions(-) diff --git a/fiftyone/operators/decorators.py b/fiftyone/operators/decorators.py index bba47684de..ea502937b0 100644 --- a/fiftyone/operators/decorators.py +++ b/fiftyone/operators/decorators.py @@ -87,8 +87,6 @@ def wrapper(*args, **kwargs): def dir_state(dirpath): if not os.path.isdir(dirpath): return None - # use glob instead of os.listdir to ignore hidden files (eg .DS_STORE) - return max( - (os.path.getmtime(f) for f in glob.glob(os.path.join(dirpath, "*"))), - default=None, - ) + # we only need to check top level dir, which will update if any subdirs + # change and in the case that files are deleted + return os.path.getmtime(dirpath) diff --git a/tests/unittests/operators/decorators_tests.py b/tests/unittests/operators/decorators_tests.py index c841242e8a..81348e7b86 100644 --- a/tests/unittests/operators/decorators_tests.py +++ b/tests/unittests/operators/decorators_tests.py @@ -6,57 +6,136 @@ | """ import os +import asyncio +import shutil +import tempfile import unittest -from unittest import mock +import time from unittest.mock import patch -from fiftyone.operators.decorators import dir_state +from fiftyone.operators.decorators import coroutine_timeout, dir_state class DirStateTests(unittest.TestCase): - @patch("glob.glob") @patch("os.path.isdir") - def test_dir_state_non_existing_dir(self, mock_isdir, mock_glob): + @patch("os.path.getmtime") + def test_dir_state_non_existing_dir(self, mock_getmtime, mock_isdir): mock_isdir.return_value = False dirpath = "/non/existing/dir" try: result = dir_state(dirpath) except Exception as e: self.fail(e) - + mock_isdir.assert_called_once_with(dirpath) self.assertIsNone(result) - assert not mock_glob.called + mock_getmtime.assert_not_called() - @patch("glob.glob") @patch("os.path.isdir") - def test_dir_state_existing_empty_dir(self, mock_isdir, mock_glob): + @patch("os.path.getmtime") + def test_dir_state_existing_empty_dir(self, mock_getmtime, mock_isdir): mock_isdir.return_value = True - mock_glob.return_value = [] dirpath = "/existing/empty/dir" + mock_getmtime.return_value = 1000 try: result = dir_state(dirpath) except Exception as e: self.fail(e) - self.assertIsNone(result) mock_isdir.assert_called_once_with(dirpath) - mock_glob.assert_called_once_with(os.path.join(dirpath, "*")) + mock_getmtime.assert_called_once_with(dirpath) + self.assertEqual(result, 1000) @patch("os.path.isdir") - @patch("glob.glob") @patch("os.path.getmtime") def test_dir_state_with_existing_nonempty_dir( - self, mock_getmtime, mock_glob, mock_isdir + self, mock_getmtime, mock_isdir ): mock_isdir.return_value = True - mock_glob.return_value = ["file1.txt", "file2.txt"] - mock_getmtime.side_effect = [1000, 2000] + mock_getmtime.return_value = 2000 result = dir_state("/my/dir/path") - self.assertEqual(result, 2000) mock_isdir.assert_called_once_with("/my/dir/path") - mock_glob.assert_called_once_with(os.path.join("/my/dir/path", "*")) - mock_getmtime.assert_has_calls( - [unittest.mock.call("file1.txt"), unittest.mock.call("file2.txt")] - ) + mock_getmtime.assert_called_once_with("/my/dir/path") + self.assertEqual(result, 2000) + + def test_rgrs_dir_state_empty(self): + with tempfile.TemporaryDirectory() as tmpdirname: + self.assertGreater(dir_state(tmpdirname), 0) + + def test_rgrs_dir_state_change_with_delete(self): + plugin_paths = ["plugin1/file1.txt", "plugin2/file2.txt"] + with tempfile.TemporaryDirectory() as tmpdirname: + initial_dir_state = dir_state(tmpdirname) + for p in plugin_paths: + time.sleep(1) + os.makedirs(os.path.join(tmpdirname, p)) + + # verify that max time is greater after adding files + dir_state1 = dir_state(tmpdirname) + self.assertGreater(dir_state1, initial_dir_state) + + # verify that max time is greater after deleting files + shutil.rmtree( + os.path.join(tmpdirname, plugin_paths[0].split("/")[0]) + ) + dir_state2 = dir_state(tmpdirname) + self.assertGreaterEqual(dir_state2, dir_state1) + time.sleep(1) + + shutil.rmtree( + os.path.join(tmpdirname, plugin_paths[1].split("/")[0]) + ) + dir_state3 = dir_state(tmpdirname) + self.assertGreaterEqual(dir_state3, dir_state2) + + def test_rgrs_dir_state_change_with_rename(self): + plugin_paths = ["plugin1/file1.txt", "plugin2/file2.txt"] + with tempfile.TemporaryDirectory() as tmpdirname: + initial_dir_state = dir_state(tmpdirname) + for p in plugin_paths: + time.sleep(1) + os.makedirs(os.path.join(tmpdirname, p)) + + # verify that max time is greater after adding files + dir_state1 = dir_state(tmpdirname) + self.assertGreater(dir_state1, initial_dir_state) + + # verify that max time is greater after renaming plugin dir + os.rename( + os.path.join(tmpdirname, plugin_paths[0].split("/")[0]), + os.path.join( + tmpdirname, plugin_paths[0].split("/")[0] + "renamed" + ), + ) + dir_state2 = dir_state(tmpdirname) + self.assertGreaterEqual(dir_state2, dir_state1) + + +async def dummy_coroutine_fn(duration): + await asyncio.sleep(duration) + return "Success" + + +@coroutine_timeout(seconds=2) +async def timeout_dummy_coroutine_fn(duration): + return await dummy_coroutine_fn(duration) + + +def non_coroutine_fn(): + pass + + +class TestCoroutineTimeoutDecorator(unittest.TestCase): + def test_successful_execution(self): + result = asyncio.run(timeout_dummy_coroutine_fn(1)) + self.assertEqual(result, "Success") + + def test_timeout_exception(self): + with self.assertRaises(TimeoutError): + asyncio.run(timeout_dummy_coroutine_fn(3)) + + def test_non_coroutine_function(self): + decorated_function = coroutine_timeout(2)(non_coroutine_fn) + with self.assertRaises(TypeError): + asyncio.run(decorated_function()) From 7e223970217437c4b068f26660b5f92edf0a239a Mon Sep 17 00:00:00 2001 From: imanjra Date: Tue, 17 Oct 2023 16:26:49 -0400 Subject: [PATCH 29/36] use FileExplorerView by default for type File --- .../src/plugins/OperatorIO/utils/index.ts | 3 +++ app/packages/operators/src/types.ts | 19 +++++++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/app/packages/core/src/plugins/OperatorIO/utils/index.ts b/app/packages/core/src/plugins/OperatorIO/utils/index.ts index a52c66ef0e..38cc58d9dc 100644 --- a/app/packages/core/src/plugins/OperatorIO/utils/index.ts +++ b/app/packages/core/src/plugins/OperatorIO/utils/index.ts @@ -9,6 +9,7 @@ const inputComponentsByType = { OneOf: "OneOfView", Tuple: "TupleView", Map: "MapView", + File: "FileExplorerView", }; const outputComponentsByType = { Object: "ObjectView", @@ -18,6 +19,7 @@ const outputComponentsByType = { List: "ListView", OneOf: "OneOfView", Tuple: "TupleView", + File: "FileExplorerView", }; const baseViews = ["View", "PromptView"]; const viewAliases = { @@ -41,6 +43,7 @@ const operatorTypeToJSONSchemaType = { OneOf: "oneOf", Tuple: "array", Map: "object", + File: "object", }; const unsupportedView = "UnsupportedView"; diff --git a/app/packages/operators/src/types.ts b/app/packages/operators/src/types.ts index a0a157b0b8..58302a8f91 100644 --- a/app/packages/operators/src/types.ts +++ b/app/packages/operators/src/types.ts @@ -22,7 +22,7 @@ class OperatorObject extends BaseType { * property it self. (default: `new Map()`) * @param properties initial properties on the object */ - constructor(public properties: Map = new Map()) { + constructor(public properties: ObjectProperties = new Map()) { super(); } /** @@ -165,17 +165,19 @@ class OperatorObject extends BaseType { tuple(name, items: ANY_TYPE[], options: any = {}) { return this.defineProperty(name, new Tuple(items), options); } + static propertiesFromJSON(json: any): ObjectProperties { + const entries: Array<[string, Property]> = Object.entries( + json.properties + ).map(([k, v]) => [k, Property.fromJSON(v)]); + return new Map(entries); + } /** * Define an `Object` operator type by providing a json representing the type * @param json json object representing the definition of the property * @returns operator type `Object` created with json provided */ static fromJSON(json: any) { - const entries = Object.entries(json.properties).map(([k, v]) => [ - k, - Property.fromJSON(v), - ]); - return new OperatorObject(new Map(entries)); + return new OperatorObject(OperatorObject.propertiesFromJSON(json)); } } export { OperatorObject as Object }; @@ -425,6 +427,10 @@ export class File extends OperatorObject { description: "The contents of the directory", }); } + + static fromJSON(json: any): File { + return new File(OperatorObject.propertiesFromJSON(json)); + } } /** @@ -1138,3 +1144,4 @@ type PropertyOptions = { description?: string; view?: View; }; +type ObjectProperties = Map; From 321920bbd038ffdf77448ea904772e170af0d44a Mon Sep 17 00:00:00 2001 From: imanjra Date: Tue, 17 Oct 2023 16:27:10 -0400 Subject: [PATCH 30/36] default view and mappings for UploadedFile type --- .../src/plugins/OperatorIO/utils/index.ts | 3 + app/packages/operators/src/types.ts | 58 ++++++++++++++- fiftyone/operators/types.py | 72 ++++++++++++++----- 3 files changed, 113 insertions(+), 20 deletions(-) diff --git a/app/packages/core/src/plugins/OperatorIO/utils/index.ts b/app/packages/core/src/plugins/OperatorIO/utils/index.ts index 38cc58d9dc..717cc66f2d 100644 --- a/app/packages/core/src/plugins/OperatorIO/utils/index.ts +++ b/app/packages/core/src/plugins/OperatorIO/utils/index.ts @@ -10,6 +10,7 @@ const inputComponentsByType = { Tuple: "TupleView", Map: "MapView", File: "FileExplorerView", + UploadedFile: "FileView", }; const outputComponentsByType = { Object: "ObjectView", @@ -20,6 +21,7 @@ const outputComponentsByType = { OneOf: "OneOfView", Tuple: "TupleView", File: "FileExplorerView", + UploadedFile: "FileView", }; const baseViews = ["View", "PromptView"]; const viewAliases = { @@ -44,6 +46,7 @@ const operatorTypeToJSONSchemaType = { Tuple: "array", Map: "object", File: "object", + UploadedFile: "object", }; const unsupportedView = "UnsupportedView"; diff --git a/app/packages/operators/src/types.ts b/app/packages/operators/src/types.ts index 58302a8f91..d8b2d48c82 100644 --- a/app/packages/operators/src/types.ts +++ b/app/packages/operators/src/types.ts @@ -165,6 +165,24 @@ class OperatorObject extends BaseType { tuple(name, items: ANY_TYPE[], options: any = {}) { return this.defineProperty(name, new Tuple(items), options); } + /** + * Define a property of type {@link File} on the object + * @param name name of the property + * @param options + * @returns newly defined property + */ + file(name, options: any = {}) { + return this.defineProperty(name, new File(), options); + } + /** + * Define a property of type {@link UploadedFile} on the object + * @param name name of the property + * @param options + * @returns newly defined property + */ + uploadedFile(name, options: any = {}) { + return this.defineProperty(name, new UploadedFile(), options); + } static propertiesFromJSON(json: any): ObjectProperties { const entries: Array<[string, Property]> = Object.entries( json.properties @@ -433,6 +451,41 @@ export class File extends OperatorObject { } } +/** + * Operator type for defining an uploaded file and its metadata. + */ + +export class UploadedFile extends OperatorObject { + constructor(public properties: Map = new Map()) { + super(properties); + this.str("name", { + label: "Name", + description: "The name of the uploaded file", + }); + this.str("type", { + label: "Type", + description: "The mime type of the uploaded file", + }); + this.int("size", { + label: "Size", + description: "The size of the uploaded file in bytes", + }); + this.str("content", { + label: "Content", + description: "The base64 encoded content of the uploaded file", + }); + this.int("last_modified", { + label: "Last Modified", + description: + "The last modified time of the uploaded file in ms since epoch", + }); + } + + static fromJSON(json: object): File { + return new UploadedFile(OperatorObject.propertiesFromJSON(json)); + } +} + /** * Operator type for defining a placement for an operator. Placement is a button * that can be rendered at various places in the app @@ -1057,6 +1110,7 @@ const TYPES = { Tuple, Map: OperatorMap, File, + UploadedFile, }; // NOTE: this should always match fiftyone/operators/types.py @@ -1125,7 +1179,9 @@ export type ANY_TYPE = | Enum | OneOf | Tuple - | OperatorMap; + | OperatorMap + | File + | UploadedFile; export type ViewOrientation = "horizontal" | "vertical"; export type ViewPropertyTypes = | string diff --git a/fiftyone/operators/types.py b/fiftyone/operators/types.py index c00ea9cdc7..d275b9adfc 100644 --- a/fiftyone/operators/types.py +++ b/fiftyone/operators/types.py @@ -212,10 +212,19 @@ def file(self, name, **kwargs): Args: name: the name of the property - view (None): the :class:`FileExplorerView` of the property + view (None): the :class:`View` of the property """ return self.define_property(name, File(), **kwargs) + def uploaded_file(self, name, **kwargs): + """Defines a property on the object that is an uploaded file. + + Args: + name: the name of the property + view (None): the :class:`View` of the property + """ + return self.define_property(name, UploadedFile(), **kwargs) + def view(self, name, view, **kwargs): """Defines a view-only property. @@ -534,6 +543,45 @@ def __init__(self, **kwargs): ) +class UploadedFile(Object): + """Represents an object with uploaded file content and its metadata in + properties. + + Properties: + name: the name of the file + type: the mime type of the file + size: the size of the file in bytes + content: the base64 encoded content of the file + last_modified: the last modified time of the file in ms since epoch + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.str( + "name", label="Name", description="The name of the uploaded file" + ) + self.str( + "type", + label="Type", + description="The mime type of the uploaded file", + ) + self.int( + "size", + label="Size", + description="The size of the uploaded file in bytes", + ) + self.str( + "content", + label="Content", + description="The base64 encoded content of the uploaded file", + ) + self.int( + "last_modified", + label="Last Modified", + description="The last modified time of the uploaded file in ms since epoch", + ) + + class View(object): """Represents a view of a :class:`Property`. @@ -992,29 +1040,15 @@ def __init__(self, **kwargs): super().__init__(**kwargs) -class UploadedFile(dict): - """Represents an uploaded file. - - Attributes: - name: the name of the file - type: the mime type of the file - size: the size of the file in bytes - content: the base64 encoded contents of the file - last_modified: the last modified time of the file in ms since epoch - """ - - def __init__(self): - pass - - class FileView(View): """Displays a file input. .. note:: - This view can be used on string or object properties. If used on a - string property, the value will be the file base64 encoded contents. - If used on an object the value will be a :class:`UploadedFile` object. + This view can be used on :class:`String` or :class:`UploadedFile` + properties. If used on a :class:`String` property, the value will be the + value will be the file base64 encoded contents. If used on a + :class:`UploadedFile`, the value will be a :class:`UploadedFile` object. Args: max_size: the maximum size of the file in bytes From 4ee429a65920c81f5d2aca1411cf28e77ab718a1 Mon Sep 17 00:00:00 2001 From: Benjamin Kane Date: Tue, 17 Oct 2023 08:03:35 -0600 Subject: [PATCH 31/36] starting release notes --- docs/source/release-notes.rst | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/source/release-notes.rst b/docs/source/release-notes.rst index 27ba3fa125..f8a42314a8 100644 --- a/docs/source/release-notes.rst +++ b/docs/source/release-notes.rst @@ -3,6 +3,25 @@ FiftyOne Release Notes .. default-role:: code +.. _release-notes-teams-v1.4.3: + +FiftyOne Teams 1.4.3 +-------------------- +*Released October 20, 2023* + +Includes all updates from :ref:`FiftyOne 0.22.2 `, plus: + +.. _release-notes-v0.22.2: + +FiftyOne 0.22.2 +--------------- +*Released October 20, 2023* + +App + +- Fixed dataset recreation across processes + `#3655 ` + .. _release-notes-teams-v1.4.2: FiftyOne Teams 1.4.2 From 10aade45a5722072587cf55cdfc58e5619232be1 Mon Sep 17 00:00:00 2001 From: Benjamin Kane Date: Tue, 17 Oct 2023 08:12:48 -0600 Subject: [PATCH 32/36] add #3645 --- docs/source/release-notes.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/source/release-notes.rst b/docs/source/release-notes.rst index f8a42314a8..6ceae803d5 100644 --- a/docs/source/release-notes.rst +++ b/docs/source/release-notes.rst @@ -21,6 +21,8 @@ App - Fixed dataset recreation across processes `#3655 ` +- Fixed the :attr:`Session.url ` + property in Colab `#3645 ` .. _release-notes-teams-v1.4.2: From 5f230f2405fab5b5cfbddb1277504eb22f48d455 Mon Sep 17 00:00:00 2001 From: topher Date: Thu, 19 Oct 2023 20:05:47 +0000 Subject: [PATCH 33/36] merge release to main, final package versions --- package/desktop/setup.py | 2 +- setup.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package/desktop/setup.py b/package/desktop/setup.py index b552a4aae1..0aa938d8ff 100644 --- a/package/desktop/setup.py +++ b/package/desktop/setup.py @@ -16,7 +16,7 @@ import shutil -VERSION = "0.31" +VERSION = "0.31.0" def get_version(): diff --git a/setup.py b/setup.py index b8d17d3557..cea074f8f1 100644 --- a/setup.py +++ b/setup.py @@ -74,9 +74,9 @@ def get_version(): "xmltodict", "universal-analytics-python3>=1.0.1,<2", # internal packages - "fiftyone-brain>=0.13.1,<0.14", - "fiftyone-db>=0.4,<0.5", - "voxel51-eta>=0.12,<0.13", + "fiftyone-brain~=0.13.2", + "fiftyone-db~=0.4", + "voxel51-eta~=0.12", ] From c3a90673e53206524e3f2df90f82755d63de2b9d Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Thu, 19 Oct 2023 13:59:51 -0700 Subject: [PATCH 34/36] 0.22.2 fiftyone release notes --- docs/source/integrations/pytorch_hub.rst | 2 + docs/source/release-notes.rst | 48 +++++++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/docs/source/integrations/pytorch_hub.rst b/docs/source/integrations/pytorch_hub.rst index e239d9d449..966fbac851 100644 --- a/docs/source/integrations/pytorch_hub.rst +++ b/docs/source/integrations/pytorch_hub.rst @@ -162,6 +162,8 @@ and use it to generate object detections: :ref:`FiftyOne Model Zoo `. You should also check out the :ref:`Ultralytics integration `! +.. _dinov2-example: + Example: DINOv2 --------------- diff --git a/docs/source/release-notes.rst b/docs/source/release-notes.rst index 6ceae803d5..e3850b2b71 100644 --- a/docs/source/release-notes.rst +++ b/docs/source/release-notes.rst @@ -17,12 +17,56 @@ FiftyOne 0.22.2 --------------- *Released October 20, 2023* +Core + +- Added a `fiftyone_max_thread_pool_workers` option to the :ref:`FiftyOne config ` +- Added a `fiftyone_max_process_pool_workers` option to the :ref:`FiftyOne config ` +- Added support for directly calling + :meth:`export() ` on + :ref:`patches views ` to export image patches + `#3651 `_ +- Fixed an `issue `_ where CVAT import fails when + ``insert_new`` is ``False`` + App - Fixed dataset recreation across processes - `#3655 ` + `#3655 `_ - Fixed the :attr:`Session.url ` - property in Colab `#3645 ` + property in Colab `#3645 `_ +- Fixed converting to patches in :ref:`grouped datasets ` when sidebar filters are present +- Fixed browser cache issues when upgrading `#3683 `_ + +Plugins + +- Use a fallback icon when an operator cannot be executed + `#3661 `_ +- :ref:`fiftyone.operators.types.FileView` now captures content as well as filename and type of the + :ref:`fiftyone.operators.types.UploadedFile` + `#3679 `_ +- Fixed issue where the ``fiftyone delegated launch`` CLI command would print confusing errors + `#3694 `_ +- Added a utility for :func:`listing operators ` + `#3694 `_ +- Added a utility for :func:`checking if an operator exists ` + `#3694 `_ +- :ref:`Number properties ` now support ``min`` and ``max`` options + in various views and validation. + `#3684 `_ +- Improved validation of primitive types in operators + `#3685 `_ +- Fixed issue where non-required property validated as required + `#3701 `_ +- Fixed an issue where plugin cache was not cleared when a plugin was deleted + `#3700 `_ +- :ref:`types.File ` now uses :ref:`types.FileExplorerView ` + by default + `#3656 https://github.com/voxel51/fiftyone/pull/3656>`_ + +Zoo + +- Fixed issue preventing :ref:`DINOv2 ` models from being loaded + `#3660 `_ .. _release-notes-teams-v1.4.2: From fe891033c57f7960bfd205c122a526635b987e35 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Thu, 19 Oct 2023 14:27:08 -0700 Subject: [PATCH 35/36] fiftyone-teams 1.4.3 release notes --- docs/source/release-notes.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/source/release-notes.rst b/docs/source/release-notes.rst index e3850b2b71..4195560070 100644 --- a/docs/source/release-notes.rst +++ b/docs/source/release-notes.rst @@ -11,6 +11,14 @@ FiftyOne Teams 1.4.3 Includes all updates from :ref:`FiftyOne 0.22.2 `, plus: +General + +- Improved dataset listing queries +- Improved error handling when listing datasets +- Fixed issues with offline access and auth errors requiring cookies to be cleared manually +- Reduced max export size of datasets to 100MB +- Users will now only *see an operator* if their role meets the required role + .. _release-notes-v0.22.2: FiftyOne 0.22.2 From d650131858b66d72896747e7051a34d261a7af55 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Thu, 19 Oct 2023 14:44:34 -0700 Subject: [PATCH 36/36] release note cleanup --- docs/source/release-notes.rst | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/source/release-notes.rst b/docs/source/release-notes.rst index 4195560070..24d5f546eb 100644 --- a/docs/source/release-notes.rst +++ b/docs/source/release-notes.rst @@ -28,13 +28,16 @@ FiftyOne 0.22.2 Core - Added a `fiftyone_max_thread_pool_workers` option to the :ref:`FiftyOne config ` + `#3654 `_ - Added a `fiftyone_max_process_pool_workers` option to the :ref:`FiftyOne config ` + `#3654 `_ - Added support for directly calling :meth:`export() ` on :ref:`patches views ` to export image patches `#3651 `_ - Fixed an `issue `_ where CVAT import fails when ``insert_new`` is ``False`` + `#3691 `_ App @@ -43,14 +46,15 @@ App - Fixed the :attr:`Session.url ` property in Colab `#3645 `_ - Fixed converting to patches in :ref:`grouped datasets ` when sidebar filters are present + `#3666 `_ - Fixed browser cache issues when upgrading `#3683 `_ Plugins - Use a fallback icon when an operator cannot be executed `#3661 `_ -- :ref:`fiftyone.operators.types.FileView` now captures content as well as filename and type of the - :ref:`fiftyone.operators.types.UploadedFile` +- :class:`fiftyone.operators.types.FileView` now captures content as well as filename and type of the + :class:`fiftyone.operators.types.UploadedFile` `#3679 `_ - Fixed issue where the ``fiftyone delegated launch`` CLI command would print confusing errors `#3694 `_ @@ -69,7 +73,7 @@ Plugins `#3700 `_ - :ref:`types.File ` now uses :ref:`types.FileExplorerView ` by default - `#3656 https://github.com/voxel51/fiftyone/pull/3656>`_ + `#3656 `_ Zoo