From 285145be592f85aaccf1576156781a961d60295f Mon Sep 17 00:00:00 2001 From: danielgural <28dang28@gmail.com> Date: Thu, 24 Oct 2024 15:20:18 -0400 Subject: [PATCH 001/104] Fixed Hugging Face Transformers not using GPU --- fiftyone/utils/transformers.py | 39 +++++++++++++++++----------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/fiftyone/utils/transformers.py b/fiftyone/utils/transformers.py index 95bc3b23e0..ee781a05bb 100644 --- a/fiftyone/utils/transformers.py +++ b/fiftyone/utils/transformers.py @@ -6,16 +6,16 @@ | `voxel51.com `_ | """ -import logging -import numpy as np +import logging import eta.core.utils as etau +import numpy as np -from fiftyone.core.config import Config import fiftyone.core.labels as fol -from fiftyone.core.models import Model, EmbeddingsMixin, PromptMixin import fiftyone.core.utils as fou +from fiftyone.core.config import Config +from fiftyone.core.models import EmbeddingsMixin, Model, PromptMixin from fiftyone.zoo.models import HasZooModel torch = fou.lazy_import("torch") @@ -451,9 +451,7 @@ class FiftyOneTransformer(TransformerEmbeddingsMixin, Model): def __init__(self, config): self.config = config self.model = self._load_model(config) - self.device = ( - "cuda" if next(self.model.parameters()).is_cuda else "cpu" - ) + self.device = "cuda" if torch.cuda.is_available() else "cpu" self.image_processor = self._load_image_processor() @property @@ -498,9 +496,7 @@ def __init__(self, config): self.config = config self.classes = config.classes self.model = self._load_model(config) - self.device = ( - "cuda" if next(self.model.parameters()).is_cuda else "cpu" - ) + self.device = "cuda" if torch.cuda.is_available() else "cpu" self.processor = self._load_processor() self._text_prompts = None @@ -749,9 +745,7 @@ def __init__(self, config): self.classes = config.classes self.processor = self._load_processor(config) self.model = self._load_model(config) - self.device = ( - "cuda" if next(self.model.parameters()).is_cuda else "cpu" - ) + self.device = "cuda" if torch.cuda.is_available() else "cpu" self._text_prompts = None def _load_processor(self, config): @@ -760,7 +754,9 @@ def _load_processor(self, config): if config.model is not None: name_or_path = config.model.name_or_path - return transformers.AutoProcessor.from_pretrained(name_or_path) + return transformers.AutoProcessor.from_pretrained(name_or_path).to( + self.device + ) def _load_model(self, config): name_or_path = config.name_or_path @@ -825,7 +821,7 @@ def _load_model(self, config): return transformers.AutoModelForObjectDetection.from_pretrained( config.name_or_path - ) + ).to(self.device) def _predict(self, inputs, target_sizes): with torch.no_grad(): @@ -876,10 +872,11 @@ def _load_model(self, config): if config.model is not None: model = config.model else: + device = "cuda" if torch.cuda.is_available() else "cpu" model = ( transformers.AutoModelForSemanticSegmentation.from_pretrained( config.name_or_path - ) + ).to(device) ) self.mask_targets = model.config.id2label @@ -929,10 +926,10 @@ class FiftyOneTransformerForDepthEstimation(FiftyOneTransformer): def _load_model(self, config): if config.model is not None: return config.model - + device = "cuda" if torch.cuda.is_available() else "cpu" return transformers.AutoModelForDepthEstimation.from_pretrained( config.name_or_path - ) + ).to(device) def _predict(self, inputs, target_sizes): with torch.no_grad(): @@ -1034,7 +1031,8 @@ def _get_model_for_image_text_retrieval(base_model, model_name_or_path): __import__(module_name, fromlist=[itr_class_name]), itr_class_name, ) - return itr_class.from_pretrained(model_name_or_path) + + return itr_class.from_pretrained(model_name_or_path).to(base_model.device) def _get_image_processor_fallback(model): @@ -1083,4 +1081,5 @@ def _get_detector_from_processor(processor, model_name_or_path): __import__(module_name, fromlist=[detector_class_name]), detector_class_name, ) - return detector_class.from_pretrained(model_name_or_path) + device = "cuda" if torch.cuda.is_available() else "cpu" + return detector_class.from_pretrained(model_name_or_path).to(device) From 3d6084fa08a941c308732b2cbf7d3ac6b00e0e67 Mon Sep 17 00:00:00 2001 From: danielgural <28dang28@gmail.com> Date: Thu, 24 Oct 2024 15:34:34 -0400 Subject: [PATCH 002/104] Fixed some classes not having device on them --- fiftyone/utils/transformers.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/fiftyone/utils/transformers.py b/fiftyone/utils/transformers.py index ee781a05bb..0b54029586 100644 --- a/fiftyone/utils/transformers.py +++ b/fiftyone/utils/transformers.py @@ -581,7 +581,10 @@ def _load_model(self, config): if config.model is not None: return config.model - model = transformers.AutoModel.from_pretrained(config.name_or_path) + device = "cuda" if torch.cuda.is_available() else "cpu" + model = transformers.AutoModel.from_pretrained(config.name_or_path).to( + device + ) if _has_image_text_retrieval(model): model = _get_model_for_image_text_retrieval( model, config.name_or_path @@ -690,10 +693,10 @@ class FiftyOneTransformerForImageClassification(FiftyOneTransformer): def _load_model(self, config): if config.model is not None: return config.model - + device = "cuda" if torch.cuda.is_available() else "cpu" return transformers.AutoModelForImageClassification.from_pretrained( config.name_or_path - ) + ).to(device) def _predict(self, inputs): with torch.no_grad(): @@ -818,10 +821,10 @@ class FiftyOneTransformerForObjectDetection(FiftyOneTransformer): def _load_model(self, config): if config.model is not None: return config.model - + device = "cuda" if torch.cuda.is_available() else "cpu" return transformers.AutoModelForObjectDetection.from_pretrained( config.name_or_path - ).to(self.device) + ).to(device) def _predict(self, inputs, target_sizes): with torch.no_grad(): From 81baea439eaff177d89ea1634fcbf717d25ea12f Mon Sep 17 00:00:00 2001 From: afoley587 <54959686+afoley587@users.noreply.github.com> Date: Wed, 4 Dec 2024 14:30:08 -0500 Subject: [PATCH 003/104] chore: Bump brain version (#5212) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1feb01edee..c4715aaeea 100644 --- a/setup.py +++ b/setup.py @@ -74,7 +74,7 @@ def get_version(): "universal-analytics-python3>=1.0.1,<2", "pydash", # internal packages - "fiftyone-brain>=0.17.0,<0.18", + "fiftyone-brain>=0.18.0,<0.19", "fiftyone-db>=0.4,<2.0", "voxel51-eta>=0.13.0,<0.14", ] From 1d7c7e77d11756b3f4298466471bb9e2d4f3f571 Mon Sep 17 00:00:00 2001 From: brimoor Date: Wed, 4 Dec 2024 23:00:14 -0500 Subject: [PATCH 004/104] bump release date --- docs/source/release-notes.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/release-notes.rst b/docs/source/release-notes.rst index 17aa4f7a62..d677d83fdd 100644 --- a/docs/source/release-notes.rst +++ b/docs/source/release-notes.rst @@ -5,7 +5,7 @@ FiftyOne Release Notes FiftyOne Teams 2.2.0 -------------------- -*Released December 4, 2024* +*Released December 5, 2024* Includes all updates from :ref:`FiftyOne 1.1.0 `, plus: @@ -30,7 +30,7 @@ Includes all updates from :ref:`FiftyOne 1.1.0 `, plus: FiftyOne 1.1.0 -------------- -*Released December 4, 2024* +*Released December 5, 2024* What's New From 68344b99e352a6ec8c01f89f68308fc7529fc82a Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Thu, 5 Dec 2024 08:11:00 -0700 Subject: [PATCH 005/104] fix issue with disabled schedule exec option --- .../operators/src/ExecutionOptionItem.tsx | 22 +++++++++ app/packages/operators/src/SplitButton.tsx | 26 ++-------- .../OperatorExecutionMenu/index.tsx | 48 +++++++++++++------ app/packages/operators/src/state.ts | 2 + 4 files changed, 60 insertions(+), 38 deletions(-) create mode 100644 app/packages/operators/src/ExecutionOptionItem.tsx diff --git a/app/packages/operators/src/ExecutionOptionItem.tsx b/app/packages/operators/src/ExecutionOptionItem.tsx new file mode 100644 index 0000000000..a87a791dab --- /dev/null +++ b/app/packages/operators/src/ExecutionOptionItem.tsx @@ -0,0 +1,22 @@ +import { useTheme } from "@fiftyone/components"; + +export default function ExecutionOptionItem({ label, tag, disabled }) { + const theme = useTheme(); + const tagEl = tag ? ( + + {tag} + + ) : null; + return ( +
+ {label} + {tagEl} +
+ ); +} diff --git a/app/packages/operators/src/SplitButton.tsx b/app/packages/operators/src/SplitButton.tsx index fbb4b1266d..d75c54430a 100644 --- a/app/packages/operators/src/SplitButton.tsx +++ b/app/packages/operators/src/SplitButton.tsx @@ -15,7 +15,7 @@ import { } from "@mui/material"; import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; import { onEnter } from "./utils"; -import { useTheme } from "@fiftyone/components"; +import ExecutionOptionItem from "./ExecutionOptionItem"; const ButtonStylesOverrides: ButtonProps["sx"] = { color: (theme) => theme.palette.text.secondary, @@ -63,6 +63,7 @@ export default function SplitButton({ }; const handleSelect = (option) => { + if (!option.onSelect) return; option.onSelect(); setOpen(false); }; @@ -142,7 +143,7 @@ export default function SplitButton({ : theme.palette.text.disabled, }} primary={ - ); } - -function PrimaryWithTag({ label, tag, disabled }) { - const theme = useTheme(); - const tagEl = tag ? ( - - {tag} - - ) : null; - return ( -
- {label} - {tagEl} -
- ); -} diff --git a/app/packages/operators/src/components/OperatorExecutionMenu/index.tsx b/app/packages/operators/src/components/OperatorExecutionMenu/index.tsx index a047776493..f0b2fd747b 100644 --- a/app/packages/operators/src/components/OperatorExecutionMenu/index.tsx +++ b/app/packages/operators/src/components/OperatorExecutionMenu/index.tsx @@ -1,6 +1,6 @@ import { Menu, MenuItem, Stack, Typography } from "@mui/material"; -import React from "react"; import { OperatorExecutionOption } from "../../state"; +import ExecutionOptionItem from "../../ExecutionOptionItem"; /** * Component which provides a context menu for executing an operator using a @@ -28,24 +28,42 @@ export const OperatorExecutionMenu = ({ return ( {executionOptions.map((target) => ( - { - onClose?.(); - onOptionClick?.(target); - target.onClick(); - }} - > - - - {target.choiceLabel ?? target.label} - - {target.description} - - + target={target} + disabled={target.isDisabledSchedule || !target.onClick} + onClose={onClose} + onOptionClick={onOptionClick} + /> ))} ); }; export default OperatorExecutionMenu; + +function Item({ target, disabled, onClose, onOptionClick }) { + return ( + { + if (disabled) return; + onClose?.(); + onOptionClick?.(target); + target.onClick(); + }} + sx={{ cursor: disabled ? "default" : "pointer" }} + > + + + + + {target.description} + + + ); +} diff --git a/app/packages/operators/src/state.ts b/app/packages/operators/src/state.ts index 6d4a87e92c..331ba2a18f 100644 --- a/app/packages/operators/src/state.ts +++ b/app/packages/operators/src/state.ts @@ -246,6 +246,7 @@ export type OperatorExecutionOption = { default?: boolean; selected?: boolean; onSelect?: () => void; + isDisabledSchedule?: boolean; }; const useOperatorPromptSubmitOptions = ( @@ -346,6 +347,7 @@ const useOperatorPromptSubmitOptions = ( id: "disabled-schedule", description: markdownDesc, isDelegated: true, + isDisabledSchedule: true, }); } From be1681de6e40cfcb3c419f41efb9f660d7d8e07b Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Thu, 5 Dec 2024 17:36:12 +0800 Subject: [PATCH 006/104] Allow to provide an email for login when pushing an annotation to an annoration backend --- fiftyone/utils/cvat.py | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/fiftyone/utils/cvat.py b/fiftyone/utils/cvat.py index c30b6811dd..046a66f304 100644 --- a/fiftyone/utils/cvat.py +++ b/fiftyone/utils/cvat.py @@ -3045,6 +3045,7 @@ class CVATBackendConfig(foua.AnnotationBackendConfig): media files on disk to upload url (None): the url of the CVAT server username (None): the CVAT username + email (None): the CVAT email password (None): the CVAT password headers (None): an optional dict of headers to add to all CVAT API requests @@ -3125,6 +3126,7 @@ def __init__( media_field="filepath", url=None, username=None, + email=None, password=None, headers=None, task_size=None, @@ -3172,6 +3174,7 @@ def __init__( # store privately so these aren't serialized self._username = username + self._email = email self._password = password self._headers = headers @@ -3183,6 +3186,14 @@ def username(self): def username(self, value): self._username = value + @property + def email(self): + return self._email + + @email.setter + def email(self, value): + self._email = value + @property def password(self): return self._password @@ -3200,10 +3211,10 @@ def headers(self, value): self._headers = value def load_credentials( - self, url=None, username=None, password=None, headers=None + self, url=None, username=None, password=None, email=None, headers=None ): self._load_parameters( - url=url, username=username, password=password, headers=headers + url=url, username=username, password=password, email=email, headers=headers ) @@ -3303,6 +3314,7 @@ def _connect_to_api(self): self.config.name, self.config.url, username=self.config.username, + email=self.config.email, password=self.config.password, headers=self.config.headers, organization=self.config.organization, @@ -3550,6 +3562,7 @@ class CVATAnnotationAPI(foua.AnnotationAPI): name: the name of the backend url: url of the CVAT server username (None): the CVAT username + email (None): the CVAT email password (None): the CVAT password headers (None): an optional dict of headers to add to all requests organization (None): the name of the organization to use when sending @@ -3561,6 +3574,7 @@ def __init__( name, url, username=None, + email=None, password=None, headers=None, organization=None, @@ -3568,6 +3582,7 @@ def __init__( self._name = name self._url = url.rstrip("/") self._username = username + self._email = email self._password = password self._headers = headers self._organization = organization @@ -3722,6 +3737,7 @@ def _setup(self): username = self._username password = self._password + email = self._email if username is None or password is None: username, password = self._prompt_username_password( @@ -3739,13 +3755,13 @@ def _setup(self): self._server_version = Version("2") try: - self._login(username, password) + self._login(username, password, email) except requests.exceptions.HTTPError as e: if e.response.status_code != 404: raise e self._server_version = Version("1") - self._login(username, password) + self._login(username, password, email) self._add_referer() self._add_organization() @@ -3782,12 +3798,12 @@ def _add_organization(self): def close(self): self._session.close() - def _login(self, username, password): + def _login(self, username, password, email): response = self._make_request( self._session.post, self.login_url, print_error_info=False, - json={"username": username, "password": password}, + json={"username": username, "password": password, "email": email}, ) if "csrftoken" in response.cookies: From f1369010d59baf03578ffeb229378c85b2f2c5df Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Thu, 5 Dec 2024 18:08:37 +0800 Subject: [PATCH 007/104] Add email for payload only if provided --- fiftyone/utils/cvat.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/fiftyone/utils/cvat.py b/fiftyone/utils/cvat.py index 046a66f304..9e7de7dd98 100644 --- a/fiftyone/utils/cvat.py +++ b/fiftyone/utils/cvat.py @@ -3799,11 +3799,17 @@ def close(self): self._session.close() def _login(self, username, password, email): + payload = { + "username": username, + "password": password, + } + if email is not None: + payload["email"] = email response = self._make_request( self._session.post, self.login_url, print_error_info=False, - json={"username": username, "password": password, "email": email}, + json=payload, ) if "csrftoken" in response.cookies: From f42e4fe5278156b5c212987c21fb4f47e69938ef Mon Sep 17 00:00:00 2001 From: brimoor Date: Thu, 5 Dec 2024 12:19:24 -0500 Subject: [PATCH 008/104] run pre-commit hooks --- fiftyone/utils/cvat.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/fiftyone/utils/cvat.py b/fiftyone/utils/cvat.py index 9e7de7dd98..1966f2b390 100644 --- a/fiftyone/utils/cvat.py +++ b/fiftyone/utils/cvat.py @@ -3214,7 +3214,11 @@ def load_credentials( self, url=None, username=None, password=None, email=None, headers=None ): self._load_parameters( - url=url, username=username, password=password, email=email, headers=headers + url=url, + username=username, + password=password, + email=email, + headers=headers, ) @@ -3755,13 +3759,13 @@ def _setup(self): self._server_version = Version("2") try: - self._login(username, password, email) + self._login(username, password, email=email) except requests.exceptions.HTTPError as e: if e.response.status_code != 404: raise e self._server_version = Version("1") - self._login(username, password, email) + self._login(username, password, email=email) self._add_referer() self._add_organization() @@ -3798,13 +3802,14 @@ def _add_organization(self): def close(self): self._session.close() - def _login(self, username, password, email): + def _login(self, username, password, email=None): payload = { "username": username, - "password": password, + "password": password, } if email is not None: payload["email"] = email + response = self._make_request( self._session.post, self.login_url, From a7cb7e566ee56e876d1d9f53605e75e5d986ab43 Mon Sep 17 00:00:00 2001 From: brimoor Date: Thu, 5 Dec 2024 12:23:33 -0500 Subject: [PATCH 009/104] added release note for 5218 --- docs/source/release-notes.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/source/release-notes.rst b/docs/source/release-notes.rst index d677d83fdd..1605878b8d 100644 --- a/docs/source/release-notes.rst +++ b/docs/source/release-notes.rst @@ -76,6 +76,9 @@ SDK - Fixed a bug that prevented users with `pydantic` installed from loading the :ref:`quickstart-3d dataset ` from the zoo `#4994 `_ +- Added optional `email` parameter to the + :ref:`CVAT integration ` + `#5218 `_ Brain From b2bf811ada48096147bdb32e04523f1e8c95eed1 Mon Sep 17 00:00:00 2001 From: Daniel Bogdoll Date: Thu, 5 Dec 2024 12:45:16 -0500 Subject: [PATCH 010/104] Fixed example for zero-shot-detection-transformer-torch --- docs/scripts/make_model_zoo_docs.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/scripts/make_model_zoo_docs.py b/docs/scripts/make_model_zoo_docs.py index 472e0b31db..67769c5e90 100644 --- a/docs/scripts/make_model_zoo_docs.py +++ b/docs/scripts/make_model_zoo_docs.py @@ -173,6 +173,17 @@ model = foz.load_zoo_model("{{ name }}") embeddings = dataset.compute_embeddings(model) + +{% elif 'zero-shot-detection-transformer-torch' in name %} + model = foz.load_zoo_model( + "{{ name }}", + classes=["person", "dog", "cat", "bird", "car", "tree", "chair"] + ) + + dataset.apply_model(model, label_field="predictions") + + session = fo.launch_app(dataset) + {% else %} model = foz.load_zoo_model("{{ name }}") From 8e85dd478be3f18874f08670f246cab901cb3cea Mon Sep 17 00:00:00 2001 From: brimoor Date: Thu, 5 Dec 2024 13:03:19 -0500 Subject: [PATCH 011/104] lint --- docs/scripts/make_model_zoo_docs.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/scripts/make_model_zoo_docs.py b/docs/scripts/make_model_zoo_docs.py index 67769c5e90..9775b9ccfb 100644 --- a/docs/scripts/make_model_zoo_docs.py +++ b/docs/scripts/make_model_zoo_docs.py @@ -173,17 +173,15 @@ model = foz.load_zoo_model("{{ name }}") embeddings = dataset.compute_embeddings(model) - {% elif 'zero-shot-detection-transformer-torch' in name %} model = foz.load_zoo_model( - "{{ name }}", - classes=["person", "dog", "cat", "bird", "car", "tree", "chair"] + "{{ name }}", + classes=["person", "dog", "cat", "bird", "car", "tree", "chair"], ) dataset.apply_model(model, label_field="predictions") session = fo.launch_app(dataset) - {% else %} model = foz.load_zoo_model("{{ name }}") From ec234cd8863eb2256e57b7255483155dd377b9bb Mon Sep 17 00:00:00 2001 From: brimoor Date: Thu, 5 Dec 2024 13:18:14 -0500 Subject: [PATCH 012/104] also applies to zero shot classification --- docs/scripts/make_model_zoo_docs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/scripts/make_model_zoo_docs.py b/docs/scripts/make_model_zoo_docs.py index 9775b9ccfb..3f6dd02f79 100644 --- a/docs/scripts/make_model_zoo_docs.py +++ b/docs/scripts/make_model_zoo_docs.py @@ -173,7 +173,7 @@ model = foz.load_zoo_model("{{ name }}") embeddings = dataset.compute_embeddings(model) -{% elif 'zero-shot-detection-transformer-torch' in name %} +{% elif 'zero-shot' in name and 'transformer' in name %} model = foz.load_zoo_model( "{{ name }}", classes=["person", "dog", "cat", "bird", "car", "tree", "chair"], From c2e2d0fa97f1aab1820b2fa57ea034aa232a509b Mon Sep 17 00:00:00 2001 From: manivoxel51 <109545780+manivoxel51@users.noreply.github.com> Date: Thu, 5 Dec 2024 11:01:41 -0800 Subject: [PATCH 013/104] Fixes treeSelection filtering of samples in group (#5227) --- .../core/src/plugins/SchemaIO/components/TreeSelectionView.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/packages/core/src/plugins/SchemaIO/components/TreeSelectionView.tsx b/app/packages/core/src/plugins/SchemaIO/components/TreeSelectionView.tsx index e8c20164a1..d4765371a7 100644 --- a/app/packages/core/src/plugins/SchemaIO/components/TreeSelectionView.tsx +++ b/app/packages/core/src/plugins/SchemaIO/components/TreeSelectionView.tsx @@ -177,7 +177,8 @@ export default function TreeSelectionView(props: ViewPropsType) { const selectedSampleIds = Object.keys(updatedState).filter((key) => { const isSample = !structure.some(([parentId]) => parentId === key) && - key !== "selectAll"; + key !== "selectAll" && + !Object.keys(updatedState[key])?.includes("indeterminate"); return isSample && updatedState[key].checked; }); From c9baa5493c5dec0c36842c41d1eda9587d9107fa Mon Sep 17 00:00:00 2001 From: Sashank Aryal Date: Thu, 5 Dec 2024 14:12:54 -0600 Subject: [PATCH 014/104] use custom getsize and center --- .../looker-3d/src/fo3d/MediaTypeFo3d.tsx | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/app/packages/looker-3d/src/fo3d/MediaTypeFo3d.tsx b/app/packages/looker-3d/src/fo3d/MediaTypeFo3d.tsx index c1da5f5a47..e829429c1d 100644 --- a/app/packages/looker-3d/src/fo3d/MediaTypeFo3d.tsx +++ b/app/packages/looker-3d/src/fo3d/MediaTypeFo3d.tsx @@ -388,7 +388,7 @@ export const MediaTypeFo3dComponent = () => { return; } - const labelBoundingBoxes = []; + const labelBoundingBoxes: THREE.Box3[] = []; for (const selectedLabel of currentSelectedLabels) { const field = selectedLabel.field; @@ -441,17 +441,23 @@ export const MediaTypeFo3dComponent = () => { labelBoundingBoxes.push(thisLabelBoundingBox); } - const unionBoundingBox = labelBoundingBoxes[0].clone(); + const unionBoundingBox: THREE.Box3 = labelBoundingBoxes[0]; for (let i = 1; i < labelBoundingBoxes.length; i++) { unionBoundingBox.union(labelBoundingBoxes[i]); } - const unionBoundingBoxCenter = unionBoundingBox.getCenter( - new THREE.Vector3() - ); - const unionBoundingBoxSize = unionBoundingBox.getSize( - new THREE.Vector3() + // center = (min + max) / 2 + let unionBoundingBoxCenter = new Vector3(); + unionBoundingBoxCenter = unionBoundingBoxCenter + .addVectors(unionBoundingBox.min, unionBoundingBox.max) + .multiplyScalar(0.5); + + // size = max - min + let unionBoundingBoxSize = new Vector3(); + unionBoundingBoxSize = unionBoundingBoxSize.subVectors( + unionBoundingBox.max, + unionBoundingBox.min ); const maxSize = Math.max( From 18892a53528a3e3f25b58d1a32e82891ca3553da Mon Sep 17 00:00:00 2001 From: Sashank Aryal Date: Thu, 5 Dec 2024 14:20:51 -0600 Subject: [PATCH 015/104] use clone --- app/packages/looker-3d/src/fo3d/MediaTypeFo3d.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/packages/looker-3d/src/fo3d/MediaTypeFo3d.tsx b/app/packages/looker-3d/src/fo3d/MediaTypeFo3d.tsx index e829429c1d..c02c7ec2aa 100644 --- a/app/packages/looker-3d/src/fo3d/MediaTypeFo3d.tsx +++ b/app/packages/looker-3d/src/fo3d/MediaTypeFo3d.tsx @@ -441,7 +441,7 @@ export const MediaTypeFo3dComponent = () => { labelBoundingBoxes.push(thisLabelBoundingBox); } - const unionBoundingBox: THREE.Box3 = labelBoundingBoxes[0]; + const unionBoundingBox: THREE.Box3 = labelBoundingBoxes[0].clone(); for (let i = 1; i < labelBoundingBoxes.length; i++) { unionBoundingBox.union(labelBoundingBoxes[i]); From e2e92926e76beaba2f4ea31cf60c83ce47a78373 Mon Sep 17 00:00:00 2001 From: brimoor Date: Thu, 5 Dec 2024 17:38:00 -0500 Subject: [PATCH 016/104] bump release date --- docs/source/release-notes.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/release-notes.rst b/docs/source/release-notes.rst index 1605878b8d..047e39e395 100644 --- a/docs/source/release-notes.rst +++ b/docs/source/release-notes.rst @@ -5,7 +5,7 @@ FiftyOne Release Notes FiftyOne Teams 2.2.0 -------------------- -*Released December 5, 2024* +*Released December 6, 2024* Includes all updates from :ref:`FiftyOne 1.1.0 `, plus: @@ -30,7 +30,7 @@ Includes all updates from :ref:`FiftyOne 1.1.0 `, plus: FiftyOne 1.1.0 -------------- -*Released December 5, 2024* +*Released December 6, 2024* What's New From e44f554a190d00e44fb75cf3e3fcd8a97f9332fb Mon Sep 17 00:00:00 2001 From: Brian Moore Date: Fri, 6 Dec 2024 22:27:54 -0500 Subject: [PATCH 017/104] fix broken docs refs (#5238) --- docs/source/release-notes.rst | 6 +++--- docs/source/teams/query_performance.rst | 2 +- docs/source/user_guide/app.rst | 24 ++++++++++++------------ 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/source/release-notes.rst b/docs/source/release-notes.rst index 047e39e395..5fa058f8cf 100644 --- a/docs/source/release-notes.rst +++ b/docs/source/release-notes.rst @@ -36,7 +36,7 @@ What's New - Added a :ref:`Model Evaluation panel ` for visually and interactively evaluating models in the FiftyOne App -- Introduced :ref:`Query Performance ` in the +- Introduced :ref:`Query Performance ` in the App, which automatically nudges you to create the necessary indexes to greatly optimize queries on large datasets - Added a :ref:`leaky splits method ` for automatically @@ -169,7 +169,7 @@ Core App -- Added a new :ref:`TimelineView ` for +- Added a new :class:`TimelineView ` for building custom animations `#4965 `_ - Fixed overlay z-index and overflow for panels @@ -480,7 +480,7 @@ What's New that allows users to build custom no-code dashboards that display statistics of interest about the current dataset (and beyond) - Added `Segment Anything 2 `_ to the - :ref:`model zoo `! + :ref:`model zoo `! `#4671 `_ - Added an :ref:`Elasticsearch integration ` for native text and image searches on FiftyOne datasets! diff --git a/docs/source/teams/query_performance.rst b/docs/source/teams/query_performance.rst index 462ff71f7b..d16f92620f 100644 --- a/docs/source/teams/query_performance.rst +++ b/docs/source/teams/query_performance.rst @@ -106,7 +106,7 @@ field's filter widget and performing queries on it will be noticably faster. before creating multiple indexes simultaneously. You can also create and manage custom indexes -:ref:`via the SDK `. +:ref:`via the SDK `. .. _query-performance-summary: diff --git a/docs/source/user_guide/app.rst b/docs/source/user_guide/app.rst index f1c89dc842..3db044fbf6 100644 --- a/docs/source/user_guide/app.rst +++ b/docs/source/user_guide/app.rst @@ -395,9 +395,9 @@ only those samples and/or labels that match the filter. :alt: app-filters :align: center -.. _app-optimize-query-performance: +.. _app-optimizing-query-performance: -Optimizing query performance +Optimizing Query Performance ---------------------------- The App's sidebar is optimized to leverage database indexes whenever possible. @@ -491,8 +491,8 @@ perform initial filters on: :ref:`App config `. For :ref:`grouped datasets `, you should create two indexes for each -field you wish to filter by in query performance mode: the field itself and a -compound index that includes the group slice name: +field you wish to filter by: the field itself and a compound index that +includes the group slice name: .. code-block:: python :linenos: @@ -542,27 +542,27 @@ field: Numeric field filters are not supported by wildcard indexes. -.. _app-disasbling-query-performance: +.. _app-disabling-query-performance: -Disabling query performance +Disabling Query Performance --------------------------- -Query performance is enabled by default for all datasets. This is generally the -recommended setting for all large datasets to ensure that queries are -performant. +:ref:`Query Performance ` is enabled by +default for all datasets. This is generally the recommended setting for all +large datasets to ensure that queries are performant. -However, in certain circumstances you may prefer to disable query performance, +However, in certain circumstances you may prefer to disable Query Performance, which enables the App's sidebar to show additional information such as label/value counts that are useful but more expensive to compute. -You can disable query performance for a particular dataset for its lifetime +You can disable Query Performance for a particular dataset for its lifetime (in your current browser) via the gear icon in the Samples panel's actions row: .. image:: /images/app/app-query-performance-disabled.gif :alt: app-query-performance-disabled :align: center -You can also disable query performance by default for all datasets by setting +You can also disable Query Performance by default for all datasets by setting `default_query_performance=False` in your :ref:`App config `. From 882055568b0fd6d83c4bcd01c5cc28eaceb16230 Mon Sep 17 00:00:00 2001 From: Brian Moore Date: Sat, 7 Dec 2024 09:49:05 -0500 Subject: [PATCH 018/104] fix broken docs refs (#5238) (#5239) --- docs/source/release-notes.rst | 6 +++--- docs/source/teams/query_performance.rst | 2 +- docs/source/user_guide/app.rst | 24 ++++++++++++------------ 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/source/release-notes.rst b/docs/source/release-notes.rst index 047e39e395..5fa058f8cf 100644 --- a/docs/source/release-notes.rst +++ b/docs/source/release-notes.rst @@ -36,7 +36,7 @@ What's New - Added a :ref:`Model Evaluation panel ` for visually and interactively evaluating models in the FiftyOne App -- Introduced :ref:`Query Performance ` in the +- Introduced :ref:`Query Performance ` in the App, which automatically nudges you to create the necessary indexes to greatly optimize queries on large datasets - Added a :ref:`leaky splits method ` for automatically @@ -169,7 +169,7 @@ Core App -- Added a new :ref:`TimelineView ` for +- Added a new :class:`TimelineView ` for building custom animations `#4965 `_ - Fixed overlay z-index and overflow for panels @@ -480,7 +480,7 @@ What's New that allows users to build custom no-code dashboards that display statistics of interest about the current dataset (and beyond) - Added `Segment Anything 2 `_ to the - :ref:`model zoo `! + :ref:`model zoo `! `#4671 `_ - Added an :ref:`Elasticsearch integration ` for native text and image searches on FiftyOne datasets! diff --git a/docs/source/teams/query_performance.rst b/docs/source/teams/query_performance.rst index 462ff71f7b..d16f92620f 100644 --- a/docs/source/teams/query_performance.rst +++ b/docs/source/teams/query_performance.rst @@ -106,7 +106,7 @@ field's filter widget and performing queries on it will be noticably faster. before creating multiple indexes simultaneously. You can also create and manage custom indexes -:ref:`via the SDK `. +:ref:`via the SDK `. .. _query-performance-summary: diff --git a/docs/source/user_guide/app.rst b/docs/source/user_guide/app.rst index f1c89dc842..3db044fbf6 100644 --- a/docs/source/user_guide/app.rst +++ b/docs/source/user_guide/app.rst @@ -395,9 +395,9 @@ only those samples and/or labels that match the filter. :alt: app-filters :align: center -.. _app-optimize-query-performance: +.. _app-optimizing-query-performance: -Optimizing query performance +Optimizing Query Performance ---------------------------- The App's sidebar is optimized to leverage database indexes whenever possible. @@ -491,8 +491,8 @@ perform initial filters on: :ref:`App config `. For :ref:`grouped datasets `, you should create two indexes for each -field you wish to filter by in query performance mode: the field itself and a -compound index that includes the group slice name: +field you wish to filter by: the field itself and a compound index that +includes the group slice name: .. code-block:: python :linenos: @@ -542,27 +542,27 @@ field: Numeric field filters are not supported by wildcard indexes. -.. _app-disasbling-query-performance: +.. _app-disabling-query-performance: -Disabling query performance +Disabling Query Performance --------------------------- -Query performance is enabled by default for all datasets. This is generally the -recommended setting for all large datasets to ensure that queries are -performant. +:ref:`Query Performance ` is enabled by +default for all datasets. This is generally the recommended setting for all +large datasets to ensure that queries are performant. -However, in certain circumstances you may prefer to disable query performance, +However, in certain circumstances you may prefer to disable Query Performance, which enables the App's sidebar to show additional information such as label/value counts that are useful but more expensive to compute. -You can disable query performance for a particular dataset for its lifetime +You can disable Query Performance for a particular dataset for its lifetime (in your current browser) via the gear icon in the Samples panel's actions row: .. image:: /images/app/app-query-performance-disabled.gif :alt: app-query-performance-disabled :align: center -You can also disable query performance by default for all datasets by setting +You can also disable Query Performance by default for all datasets by setting `default_query_performance=False` in your :ref:`App config `. From ca5c5577f5cede9108ec2d14ee5075af24122e2d Mon Sep 17 00:00:00 2001 From: Brian Moore Date: Mon, 9 Dec 2024 09:30:55 -0500 Subject: [PATCH 019/104] include more brain modules in docs builds (#5242) --- docs/generate_docs.bash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/generate_docs.bash b/docs/generate_docs.bash index 5c11b80dc4..076665e026 100755 --- a/docs/generate_docs.bash +++ b/docs/generate_docs.bash @@ -102,7 +102,7 @@ echo "Generating API docs" sphinx-apidoc --force --no-toc --separate --follow-links \ --templatedir=docs/templates/apidoc \ -o docs/source/api fiftyone \ - fiftyone/brain/internal \ + fiftyone/brain/internal/models \ fiftyone/server \ fiftyone/service \ fiftyone/management \ From f8d5d8bd64ca61b293ab025132ee8cbb8702f0a9 Mon Sep 17 00:00:00 2001 From: Jacob Marks Date: Mon, 9 Dec 2024 10:04:55 -0500 Subject: [PATCH 020/104] fix initialization --- fiftyone/utils/ultralytics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fiftyone/utils/ultralytics.py b/fiftyone/utils/ultralytics.py index 6eafa7eab2..6ee7ea222b 100644 --- a/fiftyone/utils/ultralytics.py +++ b/fiftyone/utils/ultralytics.py @@ -467,7 +467,7 @@ class FiftyOneRTDETRModelConfig(FiftyOneYOLOModelConfig): """ def __init__(self, d): - pass + super().__init__(d) class FiftyOneRTDETRModel(Model): From 517c21d60957525390d1c537d130eff7d51d11ed Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Fri, 6 Dec 2024 12:14:05 +0800 Subject: [PATCH 021/104] Update docs with addition of FIFTYONE_CVAT_EMAIL --- docs/source/integrations/cvat.rst | 3 ++- docs/source/plugins/developing_plugins.rst | 5 +++++ docs/source/plugins/using_plugins.rst | 4 ++++ docs/source/teams/secrets.rst | 2 ++ docs/source/tutorials/cvat_annotation.ipynb | 3 ++- docs/source/user_guide/annotation.rst | 3 ++- 6 files changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/source/integrations/cvat.rst b/docs/source/integrations/cvat.rst index ceab66776a..95337c7ab5 100644 --- a/docs/source/integrations/cvat.rst +++ b/docs/source/integrations/cvat.rst @@ -211,12 +211,13 @@ which can be done in a variety of ways. The recommended way to configure your CVAT login credentials is to store them in the `FIFTYONE_CVAT_USERNAME` and `FIFTYONE_CVAT_PASSWORD` environment -variables. These are automatically accessed by FiftyOne whenever a connection +variables. Optionally, you can also set the `FIFTYONE_CVAT_EMAIL` environment variable. These are automatically accessed by FiftyOne whenever a connection to CVAT is made. .. code-block:: shell export FIFTYONE_CVAT_USERNAME=... + export FIFTYONE_CVAT_EMAIL=... export FIFTYONE_CVAT_PASSWORD=... **FiftyOne annotation config** diff --git a/docs/source/plugins/developing_plugins.rst b/docs/source/plugins/developing_plugins.rst index 7a44d352bc..472375388d 100644 --- a/docs/source/plugins/developing_plugins.rst +++ b/docs/source/plugins/developing_plugins.rst @@ -251,6 +251,7 @@ plugin's `fiftyone.yml` looks like this: secrets: - FIFTYONE_CVAT_URL - FIFTYONE_CVAT_USERNAME + - FIFTYONE_CVAT_EMAIL - FIFTYONE_CVAT_PASSWORD - FIFTYONE_LABELBOX_URL - FIFTYONE_LABELBOX_API_KEY @@ -1527,6 +1528,7 @@ plugin declares the following secrets: secrets: - FIFTYONE_CVAT_URL - FIFTYONE_CVAT_USERNAME + - FIFTYONE_CVAT_EMAIL - FIFTYONE_CVAT_PASSWORD - FIFTYONE_LABELBOX_URL - FIFTYONE_LABELBOX_API_KEY @@ -1543,6 +1545,7 @@ plugin, you would set: FIFTYONE_CVAT_URL=... FIFTYONE_CVAT_USERNAME=... + FIFTYONE_CVAT_EMAIL=... FIFTYONE_CVAT_PASSWORD=... At runtime, the plugin's :ref:`execution context ` @@ -1555,6 +1558,7 @@ plugin. Operators can access these secrets via the `ctx.secrets` dict: def execute(self, ctx): url = ctx.secrets["FIFTYONE_CVAT_URL"] username = ctx.secrets["FIFTYONE_CVAT_USERNAME"] + email = ctx.secrets["FIFTYONE_CVAT_EMAIL"] password = ctx.secrets["FIFTYONE_CVAT_PASSWORD"] .. _operator-outputs: @@ -2500,6 +2504,7 @@ plugin. Panels can access these secrets via the `ctx.secrets` dict: def on_load(self, ctx): url = ctx.secrets["FIFTYONE_CVAT_URL"] username = ctx.secrets["FIFTYONE_CVAT_USERNAME"] + email = ctx.secrets["FIFTYONE_CVAT_EMAIL"] password = ctx.secrets["FIFTYONE_CVAT_PASSWORD"] .. _panel-common-patterns: diff --git a/docs/source/plugins/using_plugins.rst b/docs/source/plugins/using_plugins.rst index fc4adba6c8..3bb963b0aa 100644 --- a/docs/source/plugins/using_plugins.rst +++ b/docs/source/plugins/using_plugins.rst @@ -295,6 +295,7 @@ available metadata about a plugin: server_path /plugins/fiftyone-plugins/plugins/annotation secrets FIFTYONE_CVAT_URL FIFTYONE_CVAT_USERNAME + FIFTYONE_CVAT_EMAIL FIFTYONE_CVAT_PASSWORD FIFTYONE_LABELBOX_URL FIFTYONE_LABELBOX_API_KEY @@ -468,6 +469,7 @@ plugin declares the following secrets: secrets: - FIFTYONE_CVAT_URL - FIFTYONE_CVAT_USERNAME + - FIFTYONE_CVAT_EMAIL - FIFTYONE_CVAT_PASSWORD - FIFTYONE_LABELBOX_URL - FIFTYONE_LABELBOX_API_KEY @@ -490,6 +492,7 @@ plugin, you would set: FIFTYONE_CVAT_URL=... FIFTYONE_CVAT_USERNAME=... + FIFTYONE_CVAT_EMAIL=... FIFTYONE_CVAT_PASSWORD=... At runtime, the plugin's execution context will automatically be hydrated with @@ -502,6 +505,7 @@ secrets via the `ctx.secrets` dict: def execute(self, ctx): url = ctx.secrets["FIFTYONE_CVAT_URL"] username = ctx.secrets["FIFTYONE_CVAT_USERNAME"] + email = ctx.secrets["FIFTYONE_CVAT_EMAIL"] password = ctx.secrets["FIFTYONE_CVAT_PASSWORD"] .. _using-panels: diff --git a/docs/source/teams/secrets.rst b/docs/source/teams/secrets.rst index 5122320845..8574ebb2ca 100644 --- a/docs/source/teams/secrets.rst +++ b/docs/source/teams/secrets.rst @@ -66,6 +66,7 @@ plugin declares the following secrets: secrets: - FIFTYONE_CVAT_URL - FIFTYONE_CVAT_USERNAME + - FIFTYONE_CVAT_EMAIL - FIFTYONE_CVAT_PASSWORD - FIFTYONE_LABELBOX_URL - FIFTYONE_LABELBOX_API_KEY @@ -82,6 +83,7 @@ secrets via the ``ctx.secrets`` dict: def execute(self, ctx): url = ctx.secrets["FIFTYONE_CVAT_URL"] username = ctx.secrets["FIFTYONE_CVAT_USERNAME"] + email = ctx.secrets["FIFTYONE_CVAT_EMAIL"] password = ctx.secrets["FIFTYONE_CVAT_PASSWORD"] The ``ctx.secrets`` dict will also be automatically populated with the diff --git a/docs/source/tutorials/cvat_annotation.ipynb b/docs/source/tutorials/cvat_annotation.ipynb index 4246f7c3fb..c2d610c84a 100644 --- a/docs/source/tutorials/cvat_annotation.ipynb +++ b/docs/source/tutorials/cvat_annotation.ipynb @@ -70,6 +70,7 @@ "outputs": [], "source": [ "!export FIFTYONE_CVAT_USERNAME=\n", + "!export FIFTYONE_CVAT_EMAIL= # optional\n", "!export FIFTYONE_CVAT_PASSWORD=" ] }, @@ -421,7 +422,7 @@ }, { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAA8YAAAHCCAYAAAAtn9D+AAAgAElEQVR4XuydB5QkVdn+n66OE3pmd2cnbd4lCahgQBAUQZEkKyCIKyCgqIsECSsgOeOKgGRYkqyIiAjCBwqfZBD1AyWI/pHgLmycvDPTPT2dqvp/bs1O6AnbVd0VbnU/dc4ePcyt9773earDr++97/XlcrkceFEBKkAFqAAVoAJUgApQASpABagAFahQBXwE4wp1nsOmAlSAClABKkAFqAAVoAJUgApQAV0BgjEfBCpABagAFaACVIAKUAEqQAWoABWoaAUIxhVtPwdPBagAFaACVIAKUAEqQAWoABWgAgRjPgNUgApQASpABagAFaACVIAKUAEqUNEKEIwr2n4OngpQASpABagAFaACVIAKUAEqQAUIxnwGqAAVoAJUgApQASpABagAFaACVKCiFSAYV7T9HDwVoAJUgApQASpABagAFaACVIAKEIz5DFABKkAFqAAVoAJUgApQASpABahARStAMK5o+zl4KkAFqAAVoAJUgApQASpABagAFSAY8xmgAlSAClABKkAFqAAVoAJUgApQgYpWgGBc0fZz8FSAClABKkAFqAAVoAJUgApQASpAMOYzQAWoABWgAlSAClABKkAFqAAVoAIVrQDBuKLt5+CpABWgAlSAClABKkAFqAAVoAJUgGDMZ4AKUAEqQAWoABWgAlSAClABKkAFKloBgnFF28/BUwEqQAWoABWgAlSAClABKkAFqADBmM8AFaACVIAKUAEqQAWoABWgAlSAClS0AgTjirafg6cCVIAKUAEqQAWoABWgAlSAClABgjGfASpABagAFaACVIAKUAEqQAWoABWoaAUIxhVtPwdPBagAFaACVIAKUAEqQAWoABWgAgRjPgNUgApQASpABagAFaACVIAKUAEqUNEKEIwr2n4OngpQASpABagAFaACVIAKUAEqQAUIxnwGqAAVoAJUgApQASpABagAFaACVKCiFSAYV7T9HDwVoAJUgApQASpABagAFaACVIAKEIz5DFABKkAFqAAVoAJUgApQASpABahARStAMK5o+zl4KkAFqAAVoAJUgApQASpABagAFSAY8xmgAlSAClABKkAFqAAVoAJUgApQgYpWgGBc0fZz8FSAClABKkAFqAAVoAJUgApQASpAMOYzQAWoABWgAlSAClAB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\n", + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA8YAAAHCCAYAAAAtn9D+AAAgAElEQVR4XuydB5QkVdn+n66OE3pmd2cnbd4lCahgQBAUQZEkKyCIKyCgqIsECSsgOeOKgGRYkqyIiAjCBwqfZBD1AyWI/pHgLmycvDPTPT2dqvp/bs1O6AnbVd0VbnU/dc4ePcyt9773earDr++97/XlcrkceFEBKkAFqAAVoAJUgApQASpABagAFahQBXwE4wp1nsOmAlSAClABKkAFqAAVoAJUgApQAV0BgjEfBCpABagAFaACVIAKUAEqQAWoABWoaAUIxhVtPwdPBagAFaACVIAKUAEqQAWoABWgAgRjPgNUgApQASpABagAFaACVIAKUAEqUNEKEIwr2n4OngpQASpABagAFaACVIAKUAEqQAUIxnwGqAAVoAJUgApQASpABagAFaACVKCiFSAYV7T9HDwVoAJUgApQASpABagAFaACVIAKEIz5DFABKkAFqAAVoAJUgApQASpABahARStAMK5o+zl4KkAFqAAVoAJUgApQASpABagAFSAY8xmgAlSAClABKkAFqAAVoAJUgApQgYpWgGBc0fZz8FSAClABKkAFqAAVoAJUgApQASpAMOYzQAWoABWgAlSAClABKkAFqAAVoAIVrQDBuKLt5+CpABWgAlSAClABKkAFqAAVoAJUgGDMZ4AKUAEqQAWoABWgAlSAClABKkAFKloBgnFF28/BUwEqQAWoABWgAlSAClABKkAFqADBmM8AFaACVIAKUAEqQAWoABWgAlSAClS0AgTjirafg6cCVIAKUAEqQAWoABWgAlSAClABgjGfASpABagAFaACVIAKUAEqQAWoABWoaAUIxhVtPwdPBagAFaACVIAKUAEqQAWoABWgAgRjPgNUgApQASpABagAFaACVIAKUAEqUNEKEIwr2n4OngpQASpABagAFaACVIAKUAEqQAUIxnwGqAAVoAJUgApQASpABagAFaACVKCiFSAYV7T9HDwVoAJUgApQASpABagAFaACVIAKEIz5DFABKkAFqAAVoAJUgApQASpABahARStAMK5o+zl4KkAFqAAVoAJUgApQASpABagAFSAY8xmgAlSAClABKkAFqAAVoAJUgApQgYpWgGBc0fZz8FSAClABKkAFqAAVoAJUgApQASpAMOYzQAWoABWgAlSAClAB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", "text/plain": [ "" ] diff --git a/docs/source/user_guide/annotation.rst b/docs/source/user_guide/annotation.rst index 98805ca350..bea898b7c9 100644 --- a/docs/source/user_guide/annotation.rst +++ b/docs/source/user_guide/annotation.rst @@ -357,12 +357,13 @@ settings that you declare in this way will be passed as keyword arguments to methods like :meth:`annotate() ` whenever the corresponding backend is in use. For example, you can configure -the URL, username, and password of your CVAT server as follows: +the URL, username, email, and password of your CVAT server as follows: .. code-block:: shell export FIFTYONE_CVAT_URL=http://localhost:8080 export FIFTYONE_CVAT_USERNAME=... + export FIFTYONE_CVAT_EMAIL=... export FIFTYONE_CVAT_PASSWORD=... The `FIFTYONE_ANNOTATION_BACKENDS` environment variable can be set to a From c0590ff811925d3d356c493fbd5f900b89dc1098 Mon Sep 17 00:00:00 2001 From: brimoor Date: Mon, 9 Dec 2024 22:59:12 -0500 Subject: [PATCH 022/104] clarifications --- docs/source/integrations/cvat.rst | 7 ++++--- docs/source/plugins/developing_plugins.rst | 10 +++++----- docs/source/plugins/using_plugins.rst | 8 ++++---- docs/source/release-notes.rst | 4 ++++ docs/source/teams/secrets.rst | 4 ++-- docs/source/tutorials/cvat_annotation.ipynb | 4 ++-- docs/source/user_guide/annotation.rst | 5 +++-- 7 files changed, 24 insertions(+), 18 deletions(-) diff --git a/docs/source/integrations/cvat.rst b/docs/source/integrations/cvat.rst index 95337c7ab5..c64fe7fedf 100644 --- a/docs/source/integrations/cvat.rst +++ b/docs/source/integrations/cvat.rst @@ -211,14 +211,14 @@ which can be done in a variety of ways. The recommended way to configure your CVAT login credentials is to store them in the `FIFTYONE_CVAT_USERNAME` and `FIFTYONE_CVAT_PASSWORD` environment -variables. Optionally, you can also set the `FIFTYONE_CVAT_EMAIL` environment variable. These are automatically accessed by FiftyOne whenever a connection +variables. These are automatically accessed by FiftyOne whenever a connection to CVAT is made. .. code-block:: shell export FIFTYONE_CVAT_USERNAME=... - export FIFTYONE_CVAT_EMAIL=... export FIFTYONE_CVAT_PASSWORD=... + export FIFTYONE_CVAT_EMAIL=... # if applicable **FiftyOne annotation config** @@ -233,7 +233,8 @@ You can also store your credentials in your "cvat": { ... "username": ..., - "password": ... + "password": ..., + "email": ... # if applicable } } } diff --git a/docs/source/plugins/developing_plugins.rst b/docs/source/plugins/developing_plugins.rst index 472375388d..fe0ffb5576 100644 --- a/docs/source/plugins/developing_plugins.rst +++ b/docs/source/plugins/developing_plugins.rst @@ -251,8 +251,8 @@ plugin's `fiftyone.yml` looks like this: secrets: - FIFTYONE_CVAT_URL - FIFTYONE_CVAT_USERNAME - - FIFTYONE_CVAT_EMAIL - FIFTYONE_CVAT_PASSWORD + - FIFTYONE_CVAT_EMAIL - FIFTYONE_LABELBOX_URL - FIFTYONE_LABELBOX_API_KEY - FIFTYONE_LABELSTUDIO_URL @@ -1528,8 +1528,8 @@ plugin declares the following secrets: secrets: - FIFTYONE_CVAT_URL - FIFTYONE_CVAT_USERNAME - - FIFTYONE_CVAT_EMAIL - FIFTYONE_CVAT_PASSWORD + - FIFTYONE_CVAT_EMAIL - FIFTYONE_LABELBOX_URL - FIFTYONE_LABELBOX_API_KEY - FIFTYONE_LABELSTUDIO_URL @@ -1545,8 +1545,8 @@ plugin, you would set: FIFTYONE_CVAT_URL=... FIFTYONE_CVAT_USERNAME=... - FIFTYONE_CVAT_EMAIL=... FIFTYONE_CVAT_PASSWORD=... + FIFTYONE_CVAT_EMAIL=... At runtime, the plugin's :ref:`execution context ` is automatically hydrated with any available secrets that are declared by the @@ -1558,8 +1558,8 @@ plugin. Operators can access these secrets via the `ctx.secrets` dict: def execute(self, ctx): url = ctx.secrets["FIFTYONE_CVAT_URL"] username = ctx.secrets["FIFTYONE_CVAT_USERNAME"] - email = ctx.secrets["FIFTYONE_CVAT_EMAIL"] password = ctx.secrets["FIFTYONE_CVAT_PASSWORD"] + email = ctx.secrets["FIFTYONE_CVAT_EMAIL"] .. _operator-outputs: @@ -2504,8 +2504,8 @@ plugin. Panels can access these secrets via the `ctx.secrets` dict: def on_load(self, ctx): url = ctx.secrets["FIFTYONE_CVAT_URL"] username = ctx.secrets["FIFTYONE_CVAT_USERNAME"] - email = ctx.secrets["FIFTYONE_CVAT_EMAIL"] password = ctx.secrets["FIFTYONE_CVAT_PASSWORD"] + email = ctx.secrets["FIFTYONE_CVAT_EMAIL"] .. _panel-common-patterns: diff --git a/docs/source/plugins/using_plugins.rst b/docs/source/plugins/using_plugins.rst index 3bb963b0aa..a35727baa8 100644 --- a/docs/source/plugins/using_plugins.rst +++ b/docs/source/plugins/using_plugins.rst @@ -295,8 +295,8 @@ available metadata about a plugin: server_path /plugins/fiftyone-plugins/plugins/annotation secrets FIFTYONE_CVAT_URL FIFTYONE_CVAT_USERNAME - FIFTYONE_CVAT_EMAIL FIFTYONE_CVAT_PASSWORD + FIFTYONE_CVAT_EMAIL FIFTYONE_LABELBOX_URL FIFTYONE_LABELBOX_API_KEY FIFTYONE_LABELSTUDIO_URL @@ -469,8 +469,8 @@ plugin declares the following secrets: secrets: - FIFTYONE_CVAT_URL - FIFTYONE_CVAT_USERNAME - - FIFTYONE_CVAT_EMAIL - FIFTYONE_CVAT_PASSWORD + - FIFTYONE_CVAT_EMAIL - FIFTYONE_LABELBOX_URL - FIFTYONE_LABELBOX_API_KEY - FIFTYONE_LABELSTUDIO_URL @@ -492,8 +492,8 @@ plugin, you would set: FIFTYONE_CVAT_URL=... FIFTYONE_CVAT_USERNAME=... - FIFTYONE_CVAT_EMAIL=... FIFTYONE_CVAT_PASSWORD=... + FIFTYONE_CVAT_EMAIL=... At runtime, the plugin's execution context will automatically be hydrated with any available secrets that are declared by the plugin. Operators access these @@ -505,8 +505,8 @@ secrets via the `ctx.secrets` dict: def execute(self, ctx): url = ctx.secrets["FIFTYONE_CVAT_URL"] username = ctx.secrets["FIFTYONE_CVAT_USERNAME"] - email = ctx.secrets["FIFTYONE_CVAT_EMAIL"] password = ctx.secrets["FIFTYONE_CVAT_PASSWORD"] + email = ctx.secrets["FIFTYONE_CVAT_EMAIL"] .. _using-panels: diff --git a/docs/source/release-notes.rst b/docs/source/release-notes.rst index 5fa058f8cf..6344e829c0 100644 --- a/docs/source/release-notes.rst +++ b/docs/source/release-notes.rst @@ -51,6 +51,8 @@ App `#4931 `_ - Gracefully handle deleted + recreated datasets of the same name `#5183 `_ +- Added a `referrerPolicy` so the App can run behind reverse proxies + `#4944 `_ - Fixed a bug that prevented video playback from working for videos with unknown frame rate `#5155 `_ @@ -61,6 +63,8 @@ SDK :meth:`max() ` and aggregations `#5029 `_ +- Optimized object detection evaluation with r-trees + `#4758 `_ - Improved support for creating summary fields and indexes `#5091 `_ - Added support for creating compound indexes when using the builtin diff --git a/docs/source/teams/secrets.rst b/docs/source/teams/secrets.rst index 8574ebb2ca..783cbb88eb 100644 --- a/docs/source/teams/secrets.rst +++ b/docs/source/teams/secrets.rst @@ -66,8 +66,8 @@ plugin declares the following secrets: secrets: - FIFTYONE_CVAT_URL - FIFTYONE_CVAT_USERNAME - - FIFTYONE_CVAT_EMAIL - FIFTYONE_CVAT_PASSWORD + - FIFTYONE_CVAT_EMAIL - FIFTYONE_LABELBOX_URL - FIFTYONE_LABELBOX_API_KEY - FIFTYONE_LABELSTUDIO_URL @@ -83,8 +83,8 @@ secrets via the ``ctx.secrets`` dict: def execute(self, ctx): url = ctx.secrets["FIFTYONE_CVAT_URL"] username = ctx.secrets["FIFTYONE_CVAT_USERNAME"] - email = ctx.secrets["FIFTYONE_CVAT_EMAIL"] password = ctx.secrets["FIFTYONE_CVAT_PASSWORD"] + email = ctx.secrets["FIFTYONE_CVAT_EMAIL"] The ``ctx.secrets`` dict will also be automatically populated with the values of any environment variables whose name matches a secret key declared diff --git a/docs/source/tutorials/cvat_annotation.ipynb b/docs/source/tutorials/cvat_annotation.ipynb index c2d610c84a..b8a736a457 100644 --- a/docs/source/tutorials/cvat_annotation.ipynb +++ b/docs/source/tutorials/cvat_annotation.ipynb @@ -70,8 +70,8 @@ "outputs": [], "source": [ "!export FIFTYONE_CVAT_USERNAME=\n", - "!export FIFTYONE_CVAT_EMAIL= # optional\n", - "!export FIFTYONE_CVAT_PASSWORD=" + "!export FIFTYONE_CVAT_PASSWORD=\n", + "!export FIFTYONE_CVAT_EMAIL= # if applicable" ] }, { diff --git a/docs/source/user_guide/annotation.rst b/docs/source/user_guide/annotation.rst index bea898b7c9..b355f5b238 100644 --- a/docs/source/user_guide/annotation.rst +++ b/docs/source/user_guide/annotation.rst @@ -357,14 +357,15 @@ settings that you declare in this way will be passed as keyword arguments to methods like :meth:`annotate() ` whenever the corresponding backend is in use. For example, you can configure -the URL, username, email, and password of your CVAT server as follows: +the URL, username, password, and email (if applicable) of your CVAT server as +follows: .. code-block:: shell export FIFTYONE_CVAT_URL=http://localhost:8080 export FIFTYONE_CVAT_USERNAME=... - export FIFTYONE_CVAT_EMAIL=... export FIFTYONE_CVAT_PASSWORD=... + export FIFTYONE_CVAT_EMAIL=... # if applicable The `FIFTYONE_ANNOTATION_BACKENDS` environment variable can be set to a `list,of,backends` that you want to expose in your session, which may exclude From fe56fb367a275d4642c06cba0741d30e1fe6bb27 Mon Sep 17 00:00:00 2001 From: brimoor Date: Tue, 10 Dec 2024 10:18:05 -0500 Subject: [PATCH 023/104] add_path_to_sidebar_group util --- fiftyone/core/dataset.py | 38 +++++++----------------------------- fiftyone/core/odm/dataset.py | 38 ++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 31 deletions(-) diff --git a/fiftyone/core/dataset.py b/fiftyone/core/dataset.py index 9db20ee4c4..65e75cd910 100644 --- a/fiftyone/core/dataset.py +++ b/fiftyone/core/dataset.py @@ -45,8 +45,7 @@ import fiftyone.core.labels as fol import fiftyone.core.media as fom import fiftyone.core.metadata as fome -from fiftyone.core.odm.dataset import SampleFieldDocument -from fiftyone.core.odm.dataset import DatasetAppConfig, SidebarGroupDocument +from fiftyone.core.odm.dataset import DatasetAppConfig import fiftyone.migrations as fomi import fiftyone.core.odm as foo import fiftyone.core.sample as fos @@ -1866,35 +1865,12 @@ def create_summary_field( if sidebar_group is None: sidebar_group = "summaries" - if self.app_config.sidebar_groups is None: - sidebar_groups = DatasetAppConfig.default_sidebar_groups(self) - self.app_config.sidebar_groups = sidebar_groups - else: - sidebar_groups = self.app_config.sidebar_groups - - index_group = None - for group in sidebar_groups: - if group.name == sidebar_group: - index_group = group - else: - if field_name in group.paths: - group.paths.remove(field_name) - - if index_group is None: - index_group = SidebarGroupDocument(name=sidebar_group) - - insert_after = None - for i, group in enumerate(sidebar_groups): - if group.name == "labels": - insert_after = i - - if insert_after is None: - sidebar_groups.append(index_group) - else: - sidebar_groups.insert(insert_after + 1, index_group) - - if field_name not in index_group.paths: - index_group.paths.append(field_name) + self.app_config._add_path_to_sidebar_group( + field_name, + sidebar_group, + after_group="labels", + dataset=self, + ) if create_index: for _field_name in index_fields: diff --git a/fiftyone/core/odm/dataset.py b/fiftyone/core/odm/dataset.py index bb25a907c0..559306e5c6 100644 --- a/fiftyone/core/odm/dataset.py +++ b/fiftyone/core/odm/dataset.py @@ -624,6 +624,44 @@ def _rename_paths(self, paths, new_paths): for path, new_path in zip(paths, new_paths): self._rename_path(path, new_path) + def _add_path_to_sidebar_group( + self, + path, + sidebar_group, + after_group=None, + dataset=None, + ): + if self.sidebar_groups is None: + if dataset is None: + return + + self.sidebar_groups = self.default_sidebar_groups(dataset) + + index_group = None + for group in self.sidebar_groups: + if group.name == sidebar_group: + index_group = group + else: + if path in group.paths: + group.paths.remove(path) + + if index_group is None: + index_group = SidebarGroupDocument(name=sidebar_group) + + insert_after = None + if after_group is not None: + for i, group in enumerate(self.sidebar_groups): + if group.name == after_group: + insert_after = i + + if insert_after is None: + self.sidebar_groups.append(index_group) + else: + self.sidebar_groups.insert(insert_after + 1, index_group) + + if path not in index_group.paths: + index_group.paths.append(path) + def _make_default_sidebar_groups(sample_collection): # Possible sidebar groups From d367c15f2776e84abd7af16889d829bb43f86e21 Mon Sep 17 00:00:00 2001 From: brimoor Date: Tue, 10 Dec 2024 11:42:11 -0500 Subject: [PATCH 024/104] bump compatibility version --- fiftyone/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fiftyone/constants.py b/fiftyone/constants.py index 893204ef93..18ca73f47a 100644 --- a/fiftyone/constants.py +++ b/fiftyone/constants.py @@ -42,7 +42,7 @@ # This setting may be ``None`` if this client has no compatibility with other # versions # -COMPATIBLE_VERSIONS = ">=0.19,<1.2" +COMPATIBLE_VERSIONS = ">=0.19,<1.3" # Package metadata _META = metadata("fiftyone") From 0948c7c868d7ac5d75fb1fec3f4f9de39fd3f9bd Mon Sep 17 00:00:00 2001 From: imanjra Date: Fri, 6 Dec 2024 10:40:12 -0500 Subject: [PATCH 025/104] fix an issue where backtick can't be when editing evaluation note --- .../NativeModelEvaluationView/Evaluation.tsx | 11 +++++++++-- app/packages/operators/src/state.ts | 6 ++++-- app/packages/state/src/recoil/atoms.ts | 5 +++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx index c7f9c24a4c..d7f932b23f 100644 --- a/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx +++ b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx @@ -1,5 +1,5 @@ import { Dialog } from "@fiftyone/components"; -import { view } from "@fiftyone/state"; +import { editingFieldAtom, view } from "@fiftyone/state"; import { ArrowBack, ArrowDropDown, @@ -41,7 +41,7 @@ import { useTheme, } from "@mui/material"; import React, { useEffect, useMemo, useState } from "react"; -import { useRecoilState } from "recoil"; +import { useRecoilState, useSetRecoilState } from "recoil"; import EvaluationNotes from "./EvaluationNotes"; import EvaluationPlot from "./EvaluationPlot"; import Status from "./Status"; @@ -133,6 +133,7 @@ export default function Evaluation(props: EvaluationProps) { const triggerEvent = useTriggerEvent(); const activeFilter = useActiveFilter(evaluation, compareEvaluation); + const setEditingField = useSetRecoilState(editingFieldAtom); const closeNoteDialog = () => { setEditNoteState((note) => ({ ...note, open: false })); @@ -1295,6 +1296,12 @@ export default function Evaluation(props: EvaluationProps) { { + setEditingField(true); + }} + onBlur={() => { + setEditingField(false); + }} multiline rows={10} defaultValue={evaluationNotes} diff --git a/app/packages/operators/src/state.ts b/app/packages/operators/src/state.ts index 331ba2a18f..876dd3d6c8 100644 --- a/app/packages/operators/src/state.ts +++ b/app/packages/operators/src/state.ts @@ -851,6 +851,7 @@ export function useOperatorBrowser() { const choices = useRecoilValue(operatorBrowserChoices); const promptForInput = usePromptOperatorInput(); const isOperatorPaletteOpened = useRecoilValue(operatorPaletteOpened); + const editingField = useRecoilValue(fos.editingFieldAtom); const selectedValue = useMemo(() => { return selected ?? defaultSelected; @@ -913,7 +914,8 @@ export function useOperatorBrowser() { (e) => { if (e.key !== "`" && !isVisible) return; if (e.key === "`" && isOperatorPaletteOpened) return; - if (BROWSER_CONTROL_KEYS.includes(e.key)) e.preventDefault(); + if (BROWSER_CONTROL_KEYS.includes(e.key) && !editingField) + e.preventDefault(); switch (e.key) { case "ArrowDown": selectNext(); @@ -922,7 +924,7 @@ export function useOperatorBrowser() { selectPrevious(); break; case "`": - if (isOperatorPaletteOpened) break; + if (isOperatorPaletteOpened || editingField) break; if (isVisible) { close(); } else { diff --git a/app/packages/state/src/recoil/atoms.ts b/app/packages/state/src/recoil/atoms.ts index 2cdea1588a..fca20b2db9 100644 --- a/app/packages/state/src/recoil/atoms.ts +++ b/app/packages/state/src/recoil/atoms.ts @@ -385,3 +385,8 @@ export const escapeKeyHandlerIdsAtom = atom>({ key: "escapeKeyHandlerIdsAtom", default: new Set(), }); + +export const editingFieldAtom = atom({ + key: "editingFieldAtom", + default: false, +}); From 1594990bde82aab739177c9a7bfd0202f648dc86 Mon Sep 17 00:00:00 2001 From: brimoor Date: Wed, 11 Dec 2024 00:28:45 -0500 Subject: [PATCH 026/104] support on-disk instance segmentations in SDK --- docs/source/user_guide/using_datasets.rst | 8 +- fiftyone/core/collections.py | 3 + fiftyone/core/labels.py | 7 +- fiftyone/utils/data/exporters.py | 102 +++++++++++-------- fiftyone/utils/data/importers.py | 52 ++++++---- fiftyone/utils/labels.py | 113 +++++++++++++++++----- tests/unittests/import_export_tests.py | 109 +++++++++++++++++++++ 7 files changed, 303 insertions(+), 91 deletions(-) diff --git a/docs/source/user_guide/using_datasets.rst b/docs/source/user_guide/using_datasets.rst index 729434e07e..8bab5b83a6 100644 --- a/docs/source/user_guide/using_datasets.rst +++ b/docs/source/user_guide/using_datasets.rst @@ -2542,7 +2542,7 @@ Object detections stored in |Detections| may also have instance segmentation masks. These masks can be stored in one of two ways: either directly in the database -via the :attr:`mask` attribute, or on +via the :attr:`mask ` attribute, or on disk referenced by the :attr:`mask_path ` attribute. @@ -2605,8 +2605,10 @@ object's bounding box when visualizing in the App. , }> -Like all |Label| types, you can also add custom attributes to your detections -by dynamically adding new fields to each |Detection| instance: +Like all |Label| types, you can also add custom attributes to your instance +segmentations by dynamically adding new fields to each |Detection| instance: .. code-block:: python :linenos: diff --git a/fiftyone/core/collections.py b/fiftyone/core/collections.py index d53a25fce1..8200255fb4 100644 --- a/fiftyone/core/collections.py +++ b/fiftyone/core/collections.py @@ -10681,6 +10681,9 @@ def _get_media_fields( app_media_fields.discard("filepath") for field_name, field in schema.items(): + while isinstance(field, fof.ListField): + field = field.field + if field_name in app_media_fields: media_fields[field_name] = None elif isinstance(field, fof.EmbeddedDocumentField) and issubclass( diff --git a/fiftyone/core/labels.py b/fiftyone/core/labels.py index e8b9bd9390..e6b09d8267 100644 --- a/fiftyone/core/labels.py +++ b/fiftyone/core/labels.py @@ -409,7 +409,8 @@ class Detection(_HasAttributesDict, _HasID, _HasMedia, Label): its bounding box, which should be a 2D binary or 0/1 integer numpy array mask_path (None): the absolute path to the instance segmentation image - on disk + on disk, which should be a single-channel PNG image where any + non-zero values represent the instance's extent confidence (None): a confidence in ``[0, 1]`` for the detection index (None): an index for the object attributes ({}): a dict mapping attribute names to :class:`Attribute` @@ -532,8 +533,8 @@ def to_segmentation(self, mask=None, frame_size=None, target=255): """ if not self.has_mask: raise ValueError( - "Only detections with their `mask` attributes populated can " - "be converted to segmentations" + "Only detections with their `mask` or `mask_path` attribute " + "populated can be converted to segmentations" ) mask, target = _parse_segmentation_target(mask, frame_size, target) diff --git a/fiftyone/utils/data/exporters.py b/fiftyone/utils/data/exporters.py index e2a0780380..7a9b7da68e 100644 --- a/fiftyone/utils/data/exporters.py +++ b/fiftyone/utils/data/exporters.py @@ -12,11 +12,13 @@ import warnings from collections import defaultdict +from bson import json_util +import pydash + import eta.core.datasets as etad import eta.core.frameutils as etaf import eta.core.serial as etas import eta.core.utils as etau -from bson import json_util import fiftyone as fo import fiftyone.core.collections as foc @@ -2029,34 +2031,38 @@ def _export_frame_labels(self, sample, uuid): def _export_media_fields(self, sd): for field_name, key in self._media_fields.items(): - value = sd.get(field_name, None) - if value is None: - continue - - if key is not None: - self._export_media_field(value, field_name, key=key) - else: - self._export_media_field(sd, field_name) + self._export_media_field(sd, field_name, key=key) def _export_media_field(self, d, field_name, key=None): - if key is not None: - value = d.get(key, None) - else: - key = field_name - value = d.get(field_name, None) - + value = pydash.get(d, field_name, None) if value is None: return media_exporter = self._get_media_field_exporter(field_name) - outpath, _ = media_exporter.export(value) - if self.abs_paths: - d[key] = outpath - else: - d[key] = fou.safe_relpath( - outpath, self.export_dir, default=outpath - ) + if not isinstance(value, (list, tuple)): + value = [value] + + for _d in value: + if key is not None: + _value = _d.get(key, None) + else: + _value = _d + + if _value is None: + continue + + outpath, _ = media_exporter.export(_value) + + if not self.abs_paths: + outpath = fou.safe_relpath( + outpath, self.export_dir, default=outpath + ) + + if key is not None: + _d[key] = outpath + else: + pydash.set_(d, field_name, outpath) def _get_media_field_exporter(self, field_name): media_exporter = self._media_field_exporters.get(field_name, None) @@ -2333,33 +2339,43 @@ def _prep_sample(sd): def _export_media_fields(self, sd): for field_name, key in self._media_fields.items(): - value = sd.get(field_name, None) - if value is None: - continue + self._export_media_field(sd, field_name, key=key) + + def _export_media_field(self, d, field_name, key=None): + value = pydash.get(d, field_name, None) + if value is None: + return + media_exporter = self._get_media_field_exporter(field_name) + + if not isinstance(value, (list, tuple)): + value = [value] + + for _d in value: if key is not None: - self._export_media_field(value, field_name, key=key) + _value = _d.get(key, None) else: - self._export_media_field(sd, field_name) + _value = _d - def _export_media_field(self, d, field_name, key=None): - if key is not None: - value = d.get(key, None) - else: - key = field_name - value = d.get(field_name, None) + if _value is None: + continue - if value is None: - return + if self.export_media is not False: + # Store relative path + _, uuid = media_exporter.export(_value) + outpath = os.path.join("fields", field_name, uuid) + elif self.rel_dir is not None: + # Remove `rel_dir` prefix from path + outpath = fou.safe_relpath( + _value, self.rel_dir, default=_value + ) + else: + continue - if self.export_media is not False: - # Store relative path - media_exporter = self._get_media_field_exporter(field_name) - _, uuid = media_exporter.export(value) - d[key] = os.path.join("fields", field_name, uuid) - elif self.rel_dir is not None: - # Remove `rel_dir` prefix from path - d[key] = fou.safe_relpath(value, self.rel_dir, default=value) + if key is not None: + _d[key] = outpath + else: + pydash.set_(d, field_name, outpath) def _get_media_field_exporter(self, field_name): media_exporter = self._media_field_exporters.get(field_name, None) diff --git a/fiftyone/utils/data/importers.py b/fiftyone/utils/data/importers.py index 11c50f45a5..299827f3c0 100644 --- a/fiftyone/utils/data/importers.py +++ b/fiftyone/utils/data/importers.py @@ -14,6 +14,7 @@ from bson import json_util from mongoengine.base import get_document +import pydash import eta.core.datasets as etad import eta.core.image as etai @@ -2151,32 +2152,43 @@ def _import_runs(dataset, runs, results_dir, run_cls): def _parse_media_fields(sd, media_fields, rel_dir): for field_name, key in media_fields.items(): - value = sd.get(field_name, None) + value = pydash.get(sd, field_name, None) if value is None: continue if isinstance(value, dict): - if key is False: - try: - _cls = value.get("_cls", None) - key = get_document(_cls)._MEDIA_FIELD - except Exception as e: - logger.warning( - "Failed to infer media field for '%s'. Reason: %s", - field_name, - e, - ) - key = None - - media_fields[field_name] = key - - if key is not None: - path = value.get(key, None) - if path is not None and not os.path.isabs(path): - value[key] = os.path.join(rel_dir, path) + _parse_nested_media_field( + value, media_fields, rel_dir, field_name, key + ) + elif isinstance(value, list): + for d in value: + _parse_nested_media_field( + d, media_fields, rel_dir, field_name, key + ) elif etau.is_str(value): if not os.path.isabs(value): - sd[field_name] = os.path.join(rel_dir, value) + pydash.set_(sd, field_name, os.path.join(rel_dir, value)) + + +def _parse_nested_media_field(d, media_fields, rel_dir, field_name, key): + if key is False: + try: + _cls = d.get("_cls", None) + key = get_document(_cls)._MEDIA_FIELD + except Exception as e: + logger.warning( + "Failed to infer media field for '%s'. Reason: %s", + field_name, + e, + ) + key = None + + media_fields[field_name] = key + + if key is not None: + path = d.get(key, None) + if path is not None and not os.path.isabs(path): + d[key] = os.path.join(rel_dir, path) class ImageDirectoryImporter(UnlabeledImageDatasetImporter): diff --git a/fiftyone/utils/labels.py b/fiftyone/utils/labels.py index 7071d1f001..f28bfac205 100644 --- a/fiftyone/utils/labels.py +++ b/fiftyone/utils/labels.py @@ -155,8 +155,8 @@ def export_segmentations( overwrite=False, progress=None, ): - """Exports the segmentations (or heatmaps) stored as in-database arrays in - the specified field to images on disk. + """Exports the semantic segmentations, instance segmentations, or heatmaps + stored as in-database arrays in the specified field to images on disk. Any labels without in-memory arrays are skipped. @@ -164,7 +164,9 @@ def export_segmentations( sample_collection: a :class:`fiftyone.core.collections.SampleCollection` in_field: the name of the - :class:`fiftyone.core.labels.Segmentation` or + :class:`fiftyone.core.labels.Segmentation`, + :class:`fiftyone.core.labels.Detection`, + :class:`fiftyone.core.labels.Detections`, or :class:`fiftyone.core.labels.Heatmap` field output_dir: the directory in which to write the images rel_dir (None): an optional relative directory to strip from each input @@ -183,7 +185,9 @@ def export_segmentations( """ fov.validate_non_grouped_collection(sample_collection) fov.validate_collection_label_fields( - sample_collection, in_field, (fol.Segmentation, fol.Heatmap) + sample_collection, + in_field, + (fol.Segmentation, fol.Detection, fol.Detections, fol.Heatmap), ) samples = sample_collection.select_fields(in_field) @@ -207,16 +211,31 @@ def export_segmentations( if label is None: continue - outpath = filename_maker.get_output_path( - image.filepath, output_ext=".png" - ) - - if isinstance(label, fol.Heatmap): - if label.map is not None: - label.export_map(outpath, update=update) - else: + if isinstance(label, fol.Segmentation): + if label.mask is not None: + outpath = filename_maker.get_output_path( + image.filepath, output_ext=".png" + ) + label.export_mask(outpath, update=update) + elif isinstance(label, fol.Detection): if label.mask is not None: + outpath = filename_maker.get_output_path( + image.filepath, output_ext=".png" + ) label.export_mask(outpath, update=update) + elif isinstance(label, fol.Detections): + for detection in label.detections: + if detection.mask is not None: + outpath = filename_maker.get_output_path( + image.filepath, output_ext=".png" + ) + detection.export_mask(outpath, update=update) + elif isinstance(label, fol.Heatmap): + if label.map is not None: + outpath = filename_maker.get_output_path( + image.filepath, output_ext=".png" + ) + label.export_map(outpath, update=update) def import_segmentations( @@ -226,8 +245,8 @@ def import_segmentations( delete_images=False, progress=None, ): - """Imports the segmentations (or heatmaps) stored on disk in the specified - field to in-database arrays. + """Imports the semantic segmentations, instance segmentations, or heatmaps + stored on disk in the specified field to in-database arrays. Any labels without images on disk are skipped. @@ -235,7 +254,9 @@ def import_segmentations( sample_collection: a :class:`fiftyone.core.collections.SampleCollection` in_field: the name of the - :class:`fiftyone.core.labels.Segmentation` or + :class:`fiftyone.core.labels.Segmentation`, + :class:`fiftyone.core.labels.Detection`, + :class:`fiftyone.core.labels.Detections`, or :class:`fiftyone.core.labels.Heatmap` field update (True): whether to delete the image paths from the labels delete_images (False): whether to delete any imported images from disk @@ -245,7 +266,9 @@ def import_segmentations( """ fov.validate_non_grouped_collection(sample_collection) fov.validate_collection_label_fields( - sample_collection, in_field, (fol.Segmentation, fol.Heatmap) + sample_collection, + in_field, + (fol.Segmentation, fol.Detection, fol.Detections, fol.Heatmap), ) samples = sample_collection.select_fields(in_field) @@ -262,18 +285,33 @@ def import_segmentations( if label is None: continue - if isinstance(label, fol.Heatmap): - if label.map_path is not None: - del_path = label.map_path if delete_images else None - label.import_map(update=update) + if isinstance(label, fol.Segmentation): + if label.mask_path is not None: + del_path = label.mask_path if delete_images else None + label.import_mask(update=update) if del_path: etau.delete_file(del_path) - else: + elif isinstance(label, fol.Detection): if label.mask_path is not None: del_path = label.mask_path if delete_images else None label.import_mask(update=update) if del_path: etau.delete_file(del_path) + elif isinstance(label, fol.Detections): + for detection in label.detections: + if detection.mask_path is not None: + del_path = ( + detection.mask_path if delete_images else None + ) + detection.import_mask(update=update) + if del_path: + etau.delete_file(del_path) + elif isinstance(label, fol.Heatmap): + if label.map_path is not None: + del_path = label.map_path if delete_images else None + label.import_map(update=update) + if del_path: + etau.delete_file(del_path) def transform_segmentations( @@ -389,6 +427,9 @@ def segmentations_to_detections( out_field, mask_targets=None, mask_types="stuff", + output_dir=None, + rel_dir=None, + overwrite=False, progress=None, ): """Converts the semantic segmentations masks in the specified field of the @@ -423,6 +464,18 @@ def segmentations_to_detections( - ``"thing"`` if all classes are thing classes - a dict mapping pixel values (2D masks) or RGB hex strings (3D masks) to ``"stuff"`` or ``"thing"`` for each class + output_dir (None): an optional output directory in which to write + instance segmentation images. If none is provided, the instance + segmentations are stored in the database + rel_dir (None): an optional relative directory to strip from each input + filepath to generate a unique identifier that is joined with + ``output_dir`` to generate an output path for each instance + segmentation image. This argument allows for populating nested + subdirectories in ``output_dir`` that match the shape of the input + paths. The path is converted to an absolute path (if necessary) via + :func:`fiftyone.core.storage.normalize_path` + overwrite (False): whether to delete ``output_dir`` prior to exporting + if it exists progress (None): whether to render a progress bar (True/False), use the default value ``fiftyone.config.show_progress_bars`` (None), or a progress callback function to invoke instead @@ -438,6 +491,14 @@ def segmentations_to_detections( in_field, processing_frames = samples._handle_frame_field(in_field) out_field, _ = samples._handle_frame_field(out_field) + if overwrite and output_dir is not None: + etau.delete_dir(output_dir) + + if output_dir is not None: + filename_maker = fou.UniqueFilenameMaker( + output_dir=output_dir, rel_dir=rel_dir, idempotent=False + ) + for sample in samples.iter_samples(autosave=True, progress=progress): if processing_frames: images = sample.frames.values() @@ -449,9 +510,17 @@ def segmentations_to_detections( if label is None: continue - image[out_field] = label.to_detections( + detections = label.to_detections( mask_targets=mask_targets, mask_types=mask_types ) + if output_dir is not None: + for detection in detections.detections: + mask_path = filename_maker.get_output_path( + image.filepath, output_ext=".png" + ) + detection.export_mask(mask_path, update=True) + + image[out_field] = detections def instances_to_polylines( diff --git a/tests/unittests/import_export_tests.py b/tests/unittests/import_export_tests.py index 896429d8a7..d7f601a9e4 100644 --- a/tests/unittests/import_export_tests.py +++ b/tests/unittests/import_export_tests.py @@ -2218,6 +2218,115 @@ def _test_image_segmentation_fiftyone_dataset(self, dataset_type): dataset2.values("segmentations.mask_path"), ) + @drop_datasets + def test_instance_segmentation_fiftyone_dataset(self): + self._test_instance_segmentation_fiftyone_dataset( + fo.types.FiftyOneDataset + ) + + @drop_datasets + def test_instance_segmentation_legacy_fiftyone_dataset(self): + self._test_instance_segmentation_fiftyone_dataset( + fo.types.LegacyFiftyOneDataset + ) + + def _test_instance_segmentation_fiftyone_dataset(self, dataset_type): + dataset = self._make_dataset() + + # In-database instance segmentations + + export_dir = self._new_dir() + + dataset.export( + export_dir=export_dir, + dataset_type=dataset_type, + ) + + dataset2 = fo.Dataset.from_dir( + dataset_dir=export_dir, + dataset_type=dataset_type, + ) + + self.assertEqual(len(dataset), len(dataset2)) + self.assertEqual(dataset.count("detections.detections.mask_path"), 0) + self.assertEqual(dataset2.count("detections.detections.mask_path"), 0) + self.assertEqual( + dataset.count("detections.detections.mask"), + dataset2.count("detections.detections.mask"), + ) + + # Convert to on-disk instance segmentations + + segmentations_dir = self._new_dir() + + foul.export_segmentations(dataset, "detections", segmentations_dir) + + self.assertEqual(dataset.count("detections.detections.mask"), 0) + for mask_path in dataset.values("detections.detections[].mask_path"): + if mask_path is not None: + self.assertTrue(mask_path.startswith(segmentations_dir)) + + # On-disk instance segmentations + + export_dir = self._new_dir() + field_dir = os.path.join(export_dir, "fields", "detections.detections") + + dataset.export( + export_dir=export_dir, + dataset_type=dataset_type, + ) + + dataset2 = fo.Dataset.from_dir( + dataset_dir=export_dir, + dataset_type=dataset_type, + ) + + self.assertEqual(len(dataset), len(dataset2)) + self.assertEqual(dataset2.count("detections.detections.mask"), 0) + self.assertEqual( + dataset.count("detections.detections.mask_path"), + dataset2.count("detections.detections.mask_path"), + ) + + for mask_path in dataset2.values("detections.detections[].mask_path"): + if mask_path is not None: + self.assertTrue(mask_path.startswith(field_dir)) + + # On-disk instance segmentations (don't export media) + + export_dir = self._new_dir() + + dataset.export( + export_dir=export_dir, + dataset_type=dataset_type, + export_media=False, + ) + + dataset2 = fo.Dataset.from_dir( + dataset_dir=export_dir, + dataset_type=dataset_type, + ) + + self.assertEqual(len(dataset), len(dataset2)) + self.assertListEqual( + dataset.values("filepath"), + dataset2.values("filepath"), + ) + self.assertListEqual( + dataset.values("detections.detections[].mask_path"), + dataset2.values("detections.detections[].mask_path"), + ) + + # Convert to in-database instance segmentations + + foul.import_segmentations(dataset2, "detections") + + self.assertEqual(dataset2.count("detections.detections.mask_path"), 0) + self.assertEqual( + dataset2.count("detections.detections.mask"), + dataset.count("detections.detections.mask_path"), + ) + class DICOMDatasetTests(ImageDatasetTests): def _get_dcm_path(self): From 0a86bcf25d3c366a290802b3e491ab1679accb4f Mon Sep 17 00:00:00 2001 From: Minh-Tue Vo Date: Tue, 10 Dec 2024 23:05:36 -0800 Subject: [PATCH 027/104] Add uniqueness validation for TableView (#5170) * Added uniqueness validation to TableView * Added tests --------- Co-authored-by: minhtuevo --- fiftyone/operators/types.py | 39 +++++++++++++---- tests/unittests/operators/tableview_tests.py | 46 ++++++++++++++++++++ 2 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 tests/unittests/operators/tableview_tests.py diff --git a/fiftyone/operators/types.py b/fiftyone/operators/types.py index 2caf7ce47d..d2cda5219f 100644 --- a/fiftyone/operators/types.py +++ b/fiftyone/operators/types.py @@ -1827,16 +1827,18 @@ class Action(View): on_click: the operator to execute when the action is clicked """ - def __init__(self, **kwargs): + def __init__(self, name, **kwargs): super().__init__(**kwargs) + self.name = name def clone(self): - clone = Action(**self._kwargs) + clone = Action(self.name, **self._kwargs) return clone def to_json(self): - return {**super().to_json()} - + return {**super().to_json(), "name": self.name} + + class Tooltip(View): """A tooltip (currently supported only in a :class:`TableView`). @@ -1846,15 +1848,17 @@ class Tooltip(View): column: the column of the tooltip """ - def __init__(self, **kwargs): + def __init__(self, row, column, **kwargs): super().__init__(**kwargs) + self.row = row + self.column = column def clone(self): - clone = Tooltip(**self._kwargs) + clone = Tooltip(self.row, self.column, **self._kwargs) return clone def to_json(self): - return {**super().to_json()} + return {**super().to_json(), "row": self.row, "column": self.column} class TableView(View): @@ -1870,11 +1874,16 @@ def __init__(self, **kwargs): self.columns = kwargs.get("columns", []) self.row_actions = kwargs.get("row_actions", []) self.tooltips = kwargs.get("tooltips", []) + self._tooltip_map = {} def keys(self): return [column.key for column in self.columns] def add_column(self, key, **kwargs): + for column in self.columns: + if column.key == key: + raise ValueError(f"Column with key '{key}' already exists") + column = Column(key, **kwargs) self.columns.append(column) return column @@ -1882,6 +1891,10 @@ def add_column(self, key, **kwargs): def add_row_action( self, name, on_click, label=None, icon=None, tooltip=None, **kwargs ): + for action in self.row_actions: + if action.name == name: + raise ValueError(f"Action with name '{name}' already exists") + row_action = Action( name=name, on_click=on_click, @@ -1892,10 +1905,16 @@ def add_row_action( ) self.row_actions.append(row_action) return row_action - + def add_tooltip(self, row, column, value, **kwargs): + if (row, column) in self._tooltip_map: + raise ValueError( + f"Tooltip for row '{row}' and column '{column}' already exists" + ) + tooltip = Tooltip(row=row, column=column, value=value, **kwargs) self.tooltips.append(tooltip) + self._tooltip_map[(row, column)] = tooltip return tooltip def clone(self): @@ -1903,6 +1922,10 @@ def clone(self): clone.columns = [column.clone() for column in self.columns] clone.row_actions = [action.clone() for action in self.row_actions] clone.tooltips = [tooltip.clone() for tooltip in self.tooltips] + clone._tooltip_map = { + (tooltip.row, tooltip.column): tooltip + for tooltip in clone.tooltips + } return clone def to_json(self): diff --git a/tests/unittests/operators/tableview_tests.py b/tests/unittests/operators/tableview_tests.py new file mode 100644 index 0000000000..88945ec92c --- /dev/null +++ b/tests/unittests/operators/tableview_tests.py @@ -0,0 +1,46 @@ +import unittest + +from fiftyone.operators.types import TableView + + +class TableViewTests(unittest.TestCase): + def test_table_view_basic(self): + table = TableView() + table.add_column("column1", label="Column 1") + table.add_column("column2", label="Column 2") + assert table.keys() == ["column1", "column2"] + + with self.assertRaises(ValueError): + table.add_column("column1", label="Column 3") + + mock_on_click = lambda: None + + table.add_row_action( + "action1", + on_click=mock_on_click, + icon="icon1", + color="primary", + tooltip="Action 1", + ) + table.add_row_action( + "action2", + on_click=mock_on_click, + icon="icon2", + color="secondary", + tooltip="Action 2", + ) + + with self.assertRaises(ValueError): + table.add_row_action( + "action1", + on_click=mock_on_click, + icon="icon3", + color="primary", + tooltip="Action 3", + ) + + table.add_tooltip(1, 1, "Tooltip 1") + table.add_tooltip(1, 2, "Tooltip 2") + + with self.assertRaises(ValueError): + table.add_tooltip(1, 1, "Tooltip 3") From 867fc3bc82468711aeef32d452aa7da5846f585b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Dec 2024 10:40:13 -0500 Subject: [PATCH 028/104] Bump nanoid from 3.3.7 to 3.3.8 in /app (#5249) Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8. - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/3.3.7...3.3.8) --- updated-dependencies: - dependency-name: nanoid dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- app/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/yarn.lock b/app/yarn.lock index 93f3b05f4c..bb7e8ab020 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -13228,11 +13228,11 @@ __metadata: linkType: hard "nanoid@npm:^3.3.6, nanoid@npm:^3.3.7": - version: 3.3.7 - resolution: "nanoid@npm:3.3.7" + version: 3.3.8 + resolution: "nanoid@npm:3.3.8" bin: nanoid: bin/nanoid.cjs - checksum: d36c427e530713e4ac6567d488b489a36582ef89da1d6d4e3b87eded11eb10d7042a877958c6f104929809b2ab0bafa17652b076cdf84324aa75b30b722204f2 + checksum: dfe0adbc0c77e9655b550c333075f51bb28cfc7568afbf3237249904f9c86c9aaaed1f113f0fddddba75673ee31c758c30c43d4414f014a52a7a626efc5958c9 languageName: node linkType: hard From d626e60c637bca53829dd7b82959e6f3f845f684 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Dec 2024 16:30:43 +0000 Subject: [PATCH 029/104] Bump nanoid from 3.3.7 to 3.3.8 in /e2e-pw (#5257) Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8. - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/3.3.7...3.3.8) --- updated-dependencies: - dependency-name: nanoid dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- e2e-pw/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/e2e-pw/yarn.lock b/e2e-pw/yarn.lock index d9ad021c5f..1fb4f76cbd 100644 --- a/e2e-pw/yarn.lock +++ b/e2e-pw/yarn.lock @@ -2595,11 +2595,11 @@ __metadata: linkType: hard "nanoid@npm:^3.3.7": - version: 3.3.7 - resolution: "nanoid@npm:3.3.7" + version: 3.3.8 + resolution: "nanoid@npm:3.3.8" bin: nanoid: bin/nanoid.cjs - checksum: 10/ac1eb60f615b272bccb0e2b9cd933720dad30bf9708424f691b8113826bb91aca7e9d14ef5d9415a6ba15c266b37817256f58d8ce980c82b0ba3185352565679 + checksum: 10/2d1766606cf0d6f47b6f0fdab91761bb81609b2e3d367027aff45e6ee7006f660fb7e7781f4a34799fe6734f1268eeed2e37a5fdee809ade0c2d4eb11b0f9c40 languageName: node linkType: hard From 6766a55c5097f4cc3a333559a9561d44d1ec9488 Mon Sep 17 00:00:00 2001 From: afoley587 <54959686+afoley587@users.noreply.github.com> Date: Wed, 11 Dec 2024 12:31:43 -0500 Subject: [PATCH 030/104] chore(ci): Wrapping FFMPEG and updating actions (#5258) --- .github/workflows/e2e.yml | 9 +++++++-- .github/workflows/pr.yml | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 23120eb6c6..2fae78b497 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -68,8 +68,13 @@ jobs: python tests/utils/setup_config.py python tests/utils/github_actions_flags.py - - name: FFmpeg - uses: FedericoCarboni/setup-ffmpeg@v3 + # - name: Setup FFmpeg (with retries) + # uses: FedericoCarboni/setup-ffmpeg@v3 + + # Use this until https://github.com/federicocarboni/setup-ffmpeg/pull/23 + # is merged or the maintainer addresses the root issue. + - name: Setup FFmpeg (with retries) + uses: afoley587/setup-ffmpeg@main - name: Cache E2E Node Modules id: e2e-node-cache diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 773b009678..00a9d7922c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -30,6 +30,7 @@ jobs: - 'app/**' e2e-pw: - 'e2e-pw/**' + - '.github/workflows/e2e.yml' fiftyone: - 'fiftyone/**' - 'package/**' From 9fae650b6119f640d146c8d039402da62d047f0b Mon Sep 17 00:00:00 2001 From: imanjra Date: Fri, 6 Dec 2024 14:06:47 -0500 Subject: [PATCH 031/104] fix hover background for panel in add panel popover --- app/packages/spaces/src/components/AddPanelButton.tsx | 2 +- app/packages/spaces/src/components/AddPanelItem.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/packages/spaces/src/components/AddPanelButton.tsx b/app/packages/spaces/src/components/AddPanelButton.tsx index 1deff2d616..3c0b09c1a8 100644 --- a/app/packages/spaces/src/components/AddPanelButton.tsx +++ b/app/packages/spaces/src/components/AddPanelButton.tsx @@ -121,7 +121,7 @@ function PanelCategories({ children }) { function PanelCategory({ label, children }) { const theme = useTheme(); return ( - + From 68eb682a998bebae98ff2a05f6cdffa3a87e98aa Mon Sep 17 00:00:00 2001 From: brimoor Date: Thu, 12 Dec 2024 14:55:35 -0500 Subject: [PATCH 032/104] handle nested roots --- fiftyone/core/collections.py | 31 +++++++++++++++++++++---------- fiftyone/utils/data/exporters.py | 4 ++-- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/fiftyone/core/collections.py b/fiftyone/core/collections.py index 8200255fb4..65b6a78e39 100644 --- a/fiftyone/core/collections.py +++ b/fiftyone/core/collections.py @@ -10662,9 +10662,7 @@ def _handle_db_fields(self, paths, frames=False): db_fields_map = self._get_db_fields_map(frames=frames) return [db_fields_map.get(p, p) for p in paths] - def _get_media_fields( - self, include_filepath=True, whitelist=None, frames=False - ): + def _get_media_fields(self, whitelist=None, blacklist=None, frames=False): media_fields = {} if frames: @@ -10674,11 +10672,8 @@ def _get_media_fields( schema = self.get_field_schema(flat=True) app_media_fields = set(self._dataset.app_config.media_fields) - if include_filepath: - # 'filepath' should already be in set, but add it just in case - app_media_fields.add("filepath") - else: - app_media_fields.discard("filepath") + # 'filepath' should already be in set, but add it just in case + app_media_fields.add("filepath") for field_name, field in schema.items(): while isinstance(field, fof.ListField): @@ -10698,7 +10693,21 @@ def _get_media_fields( whitelist = {whitelist} media_fields = { - k: v for k, v in media_fields.items() if k in whitelist + k: v + for k, v in media_fields.items() + if any(w == k or k.startswith(w + ".") for w in whitelist) + } + + if blacklist is not None: + if etau.is_container(blacklist): + blacklist = set(blacklist) + else: + blacklist = {blacklist} + + media_fields = { + k: v + for k, v in media_fields.items() + if not any(w == k or k.startswith(w + ".") for w in blacklist) } return media_fields @@ -10714,7 +10723,9 @@ def _resolve_media_field(self, media_field): if leaf is not None: leaf = root + "." + leaf - if _media_field in (root, leaf): + if _media_field in (root, leaf) or root.startswith( + _media_field + "." + ): _resolved_field = leaf if leaf is not None else root if is_frame_field: _resolved_field = self._FRAMES_PREFIX + _resolved_field diff --git a/fiftyone/utils/data/exporters.py b/fiftyone/utils/data/exporters.py index 7a9b7da68e..475e4286b3 100644 --- a/fiftyone/utils/data/exporters.py +++ b/fiftyone/utils/data/exporters.py @@ -1894,7 +1894,7 @@ def log_collection(self, sample_collection): self._metadata["frame_fields"] = schema self._media_fields = sample_collection._get_media_fields( - include_filepath=False + blacklist="filepath", ) info = dict(sample_collection.info) @@ -2202,7 +2202,7 @@ def export_samples(self, sample_collection, progress=None): _sample_collection = sample_collection self._media_fields = sample_collection._get_media_fields( - include_filepath=False + blacklist="filepath" ) logger.info("Exporting samples...") From 833166132b9c2feb494e8f5e58f0ac1276d19735 Mon Sep 17 00:00:00 2001 From: brimoor Date: Thu, 12 Dec 2024 17:09:40 -0500 Subject: [PATCH 033/104] handle list fields --- fiftyone/core/collections.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/fiftyone/core/collections.py b/fiftyone/core/collections.py index 65b6a78e39..2aafc32ee2 100644 --- a/fiftyone/core/collections.py +++ b/fiftyone/core/collections.py @@ -10712,9 +10712,9 @@ def _get_media_fields(self, whitelist=None, blacklist=None, frames=False): return media_fields - def _resolve_media_field(self, media_field): + def _parse_media_field(self, media_field): if media_field in self._dataset.app_config.media_fields: - return media_field + return media_field, None _media_field, is_frame_field = self._handle_frame_field(media_field) @@ -10730,7 +10730,13 @@ def _resolve_media_field(self, media_field): if is_frame_field: _resolved_field = self._FRAMES_PREFIX + _resolved_field - return _resolved_field + _list_fields = self._parse_field_name( + _resolved_field, auto_unwind=False + )[-2] + if _list_fields: + return _resolved_field, _list_fields[0] + + return _resolved_field, None raise ValueError("'%s' is not a valid media field" % media_field) From 038138e6b697709cdd1ad810ae79b3e014790792 Mon Sep 17 00:00:00 2001 From: Sashank Aryal Date: Mon, 9 Dec 2024 14:27:32 -0600 Subject: [PATCH 034/104] optimize main thread to worker transfer when recoloring --- app/packages/looker/src/lookers/abstract.ts | 55 ++++++++++++++----- app/packages/looker/src/overlays/base.ts | 4 +- app/packages/looker/src/overlays/detection.ts | 6 ++ app/packages/looker/src/overlays/heatmap.ts | 9 ++- .../looker/src/overlays/segmentation.ts | 9 ++- .../looker/src/worker/disk-overlay-decoder.ts | 6 +- 6 files changed, 68 insertions(+), 21 deletions(-) diff --git a/app/packages/looker/src/lookers/abstract.ts b/app/packages/looker/src/lookers/abstract.ts index 3eca774de8..81b9c9ab1e 100644 --- a/app/packages/looker/src/lookers/abstract.ts +++ b/app/packages/looker/src/lookers/abstract.ts @@ -23,7 +23,7 @@ import { import { Events } from "../elements/base"; import { COMMON_SHORTCUTS, LookerElement } from "../elements/common"; import { ClassificationsOverlay, loadOverlays } from "../overlays"; -import { CONTAINS, Overlay } from "../overlays/base"; +import { CONTAINS, LabelMask, Overlay } from "../overlays/base"; import processOverlays from "../processOverlays"; import { BaseState, @@ -515,7 +515,29 @@ export abstract class AbstractLooker< abstract updateOptions(options: Partial): void; updateSample(sample: Sample) { - this.loadSample(sample); + // collect any mask targets array buffer that overalys might have + // we'll transfer that to the worker instead of copying it + const arrayBuffers: ArrayBuffer[] = []; + + for (const overlay of this.pluckedOverlays ?? []) { + // we paint overlays again, so cleanup the old ones + // this helps prevent memory leaks from, for instance, dangling ImageBitmaps + overlay.cleanup(); + + let overlayData: LabelMask = null; + + if ("mask" in overlay.label) { + overlayData = overlay.label.mask as LabelMask; + } else if ("map" in overlay.label) { + overlayData = overlay.label.map as LabelMask; + } + + if (overlayData?.data?.buffer) { + arrayBuffers.push(overlayData.data.buffer); + } + } + + this.loadSample(sample, arrayBuffers); } getSample(): Promise { @@ -698,7 +720,7 @@ export abstract class AbstractLooker< ); } - private loadSample(sample: Sample) { + private loadSample(sample: Sample, transfer: Transferable[] = []) { const messageUUID = uuid(); const labelsWorker = getLabelsWorker(); @@ -719,18 +741,21 @@ export abstract class AbstractLooker< labelsWorker.addEventListener("message", listener); - labelsWorker.postMessage({ - sample: sample as ProcessSample["sample"], - method: "processSample", - coloring: this.state.options.coloring, - customizeColorSetting: this.state.options.customizeColorSetting, - colorscale: this.state.options.colorscale, - labelTagColors: this.state.options.labelTagColors, - selectedLabelTags: this.state.options.selectedLabelTags, - sources: this.state.config.sources, - schema: this.state.config.fieldSchema, - uuid: messageUUID, - } as ProcessSample); + labelsWorker.postMessage( + { + sample: sample as ProcessSample["sample"], + method: "processSample", + coloring: this.state.options.coloring, + customizeColorSetting: this.state.options.customizeColorSetting, + colorscale: this.state.options.colorscale, + labelTagColors: this.state.options.labelTagColors, + selectedLabelTags: this.state.options.selectedLabelTags, + sources: this.state.config.sources, + schema: this.state.config.fieldSchema, + uuid: messageUUID, + } as ProcessSample, + transfer + ); } } diff --git a/app/packages/looker/src/overlays/base.ts b/app/packages/looker/src/overlays/base.ts index a3ec867766..9b433e9400 100644 --- a/app/packages/looker/src/overlays/base.ts +++ b/app/packages/looker/src/overlays/base.ts @@ -42,6 +42,7 @@ export interface SelectData { export type LabelMask = { bitmap?: ImageBitmap; + closedBitmapDims?: { width: number; height: number }; data?: OverlayMask; }; @@ -67,6 +68,7 @@ export interface Overlay> { draw(ctx: CanvasRenderingContext2D, state: State): void; isShown(state: Readonly): boolean; field?: string; + label?: BaseLabel; containsPoint(state: Readonly): CONTAINS; getMouseDistance(state: Readonly): number; getPointInfo(state: Readonly): any; @@ -82,7 +84,7 @@ export abstract class CoordinateOverlay< > implements Overlay { readonly field: string; - protected label: Label; + readonly label: Label; constructor(field: string, label: Label) { this.field = field; diff --git a/app/packages/looker/src/overlays/detection.ts b/app/packages/looker/src/overlays/detection.ts index 137beae7b4..d5b80d9873 100644 --- a/app/packages/looker/src/overlays/detection.ts +++ b/app/packages/looker/src/overlays/detection.ts @@ -263,8 +263,14 @@ export default class DetectionOverlay< public cleanup(): void { if (this.label.mask?.bitmap) { + // store height and width in bitmap object since it might be used again + const height = this.label.mask.bitmap.height; + const width = this.label.mask.bitmap.width; + this.label.mask?.bitmap.close(); this.label.mask.bitmap = null; + + this.label.mask.closedBitmapDims = { width, height }; } } } diff --git a/app/packages/looker/src/overlays/heatmap.ts b/app/packages/looker/src/overlays/heatmap.ts index e8e8817643..c1be209ed5 100644 --- a/app/packages/looker/src/overlays/heatmap.ts +++ b/app/packages/looker/src/overlays/heatmap.ts @@ -39,7 +39,7 @@ export default class HeatmapOverlay implements Overlay { readonly field: string; - private label: HeatmapLabel; + readonly label: HeatmapLabel; private targets?: TypedArray; private readonly range: [number, number]; @@ -208,7 +208,14 @@ export default class HeatmapOverlay public cleanup(): void { if (this.label.map?.bitmap) { + // store height and width in bitmap object since it might be used again + const height = this.label.map.bitmap.height; + const width = this.label.map.bitmap.width; + this.label.map?.bitmap.close(); + this.label.map.bitmap = null; + + this.label.map.closedBitmapDims = { width, height }; } } } diff --git a/app/packages/looker/src/overlays/segmentation.ts b/app/packages/looker/src/overlays/segmentation.ts index a4cb098254..3218db80a6 100644 --- a/app/packages/looker/src/overlays/segmentation.ts +++ b/app/packages/looker/src/overlays/segmentation.ts @@ -30,7 +30,7 @@ export default class SegmentationOverlay implements Overlay { readonly field: string; - private label: SegmentationLabel; + readonly label: SegmentationLabel; private targets?: TypedArray; private isRgbMaskTargets = false; @@ -263,7 +263,14 @@ export default class SegmentationOverlay public cleanup(): void { if (this.label.mask?.bitmap) { + // store height and width in bitmap object since it might be used again + const height = this.label.mask.bitmap.height; + const width = this.label.mask.bitmap.width; + this.label.mask?.bitmap.close(); + this.label.mask.bitmap = null; + + this.label.mask.closedBitmapDims = { width, height }; } } } diff --git a/app/packages/looker/src/worker/disk-overlay-decoder.ts b/app/packages/looker/src/worker/disk-overlay-decoder.ts index 8730f74bf0..e2cbf6081b 100644 --- a/app/packages/looker/src/worker/disk-overlay-decoder.ts +++ b/app/packages/looker/src/worker/disk-overlay-decoder.ts @@ -56,11 +56,11 @@ export const decodeOverlayOnDisk = async ( // it's possible we're just re-coloring, in which case re-init mask image and set bitmap to null if ( label[overlayField] && - label[overlayField].bitmap && + label[overlayField].closedBitmapDims && !label[overlayField].image ) { - const height = label[overlayField].bitmap.height; - const width = label[overlayField].bitmap.width; + const height = label[overlayField].closedBitmapDims.height; + const width = label[overlayField].closedBitmapDims.width; label[overlayField].image = new ArrayBuffer(height * width * 4); label[overlayField].bitmap.close(); label[overlayField].bitmap = null; From ece501a06c7b7e4cb3d9554a1f3c683cd61b9ac2 Mon Sep 17 00:00:00 2001 From: Sashank Aryal Date: Mon, 9 Dec 2024 14:34:57 -0600 Subject: [PATCH 035/104] fix typo --- app/packages/looker/src/lookers/abstract.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/packages/looker/src/lookers/abstract.ts b/app/packages/looker/src/lookers/abstract.ts index 81b9c9ab1e..423656e49a 100644 --- a/app/packages/looker/src/lookers/abstract.ts +++ b/app/packages/looker/src/lookers/abstract.ts @@ -515,7 +515,7 @@ export abstract class AbstractLooker< abstract updateOptions(options: Partial): void; updateSample(sample: Sample) { - // collect any mask targets array buffer that overalys might have + // collect any mask targets array buffer that overlays might have // we'll transfer that to the worker instead of copying it const arrayBuffers: ArrayBuffer[] = []; From 89fea41f0829bc58db41bddfd19668dc62b78aea Mon Sep 17 00:00:00 2001 From: Sashank Aryal Date: Mon, 9 Dec 2024 18:53:49 -0600 Subject: [PATCH 036/104] check if array buffer detached --- app/packages/looker/src/lookers/abstract.ts | 64 +++++++++++++++------ app/packages/looker/src/worker/index.ts | 4 ++ 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/app/packages/looker/src/lookers/abstract.ts b/app/packages/looker/src/lookers/abstract.ts index 423656e49a..6234ae6082 100644 --- a/app/packages/looker/src/lookers/abstract.ts +++ b/app/packages/looker/src/lookers/abstract.ts @@ -532,12 +532,30 @@ export abstract class AbstractLooker< overlayData = overlay.label.map as LabelMask; } - if (overlayData?.data?.buffer) { - arrayBuffers.push(overlayData.data.buffer); + const buffer = overlayData?.data?.buffer; + + if (!buffer) { + continue; + } + + // check for detached buffer (happens if user is switching colors too fast) + // note: ArrayBuffer.prototype.detached is a new browser API + if (typeof buffer.detached !== "undefined") { + if (buffer.detached) { + // most likely sample is already being processed, skip update + return; + } else { + arrayBuffers.push(buffer); + } + } else { + // hope we don't run into this edge case (old browser) + // if we do, we'll just copy the buffer + // might get a DataCloneError if user is switching colors too fast + arrayBuffers.push(buffer); } } - this.loadSample(sample, arrayBuffers); + this.loadSample(sample, arrayBuffers.flat()); } getSample(): Promise { @@ -741,21 +759,31 @@ export abstract class AbstractLooker< labelsWorker.addEventListener("message", listener); - labelsWorker.postMessage( - { - sample: sample as ProcessSample["sample"], - method: "processSample", - coloring: this.state.options.coloring, - customizeColorSetting: this.state.options.customizeColorSetting, - colorscale: this.state.options.colorscale, - labelTagColors: this.state.options.labelTagColors, - selectedLabelTags: this.state.options.selectedLabelTags, - sources: this.state.config.sources, - schema: this.state.config.fieldSchema, - uuid: messageUUID, - } as ProcessSample, - transfer - ); + const workerArgs = { + sample: sample as ProcessSample["sample"], + method: "processSample", + coloring: this.state.options.coloring, + customizeColorSetting: this.state.options.customizeColorSetting, + colorscale: this.state.options.colorscale, + labelTagColors: this.state.options.labelTagColors, + selectedLabelTags: this.state.options.selectedLabelTags, + sources: this.state.config.sources, + schema: this.state.config.fieldSchema, + uuid: messageUUID, + } as ProcessSample; + + try { + labelsWorker.postMessage(workerArgs, transfer); + } catch (error) { + // rarely we'll get a DataCloneError + // if one of the buffers is detached and we didn't catch it + // try again without transferring the buffers (copying them) + if (error.name === "DataCloneError") { + labelsWorker.postMessage(workerArgs); + } else { + throw error; + } + } } } diff --git a/app/packages/looker/src/worker/index.ts b/app/packages/looker/src/worker/index.ts index dcf0b2e79b..3f1e6cefeb 100644 --- a/app/packages/looker/src/worker/index.ts +++ b/app/packages/looker/src/worker/index.ts @@ -327,6 +327,10 @@ const processSample = async ({ labelTagColors, schema, }: ProcessSample) => { + if (!sample) { + return; + } + mapId(sample); const imageBitmapPromises: Promise[] = []; From d3bf17472338ec0838f03800dee5f22cac12736b Mon Sep 17 00:00:00 2001 From: Sashank Aryal Date: Tue, 10 Dec 2024 10:48:39 -0600 Subject: [PATCH 037/104] add clarifying comments --- app/packages/looker/src/lookers/abstract.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/packages/looker/src/lookers/abstract.ts b/app/packages/looker/src/lookers/abstract.ts index 6234ae6082..499e2634a5 100644 --- a/app/packages/looker/src/lookers/abstract.ts +++ b/app/packages/looker/src/lookers/abstract.ts @@ -547,9 +547,10 @@ export abstract class AbstractLooker< } else { arrayBuffers.push(buffer); } - } else { + } else if (buffer.byteLength) { // hope we don't run into this edge case (old browser) - // if we do, we'll just copy the buffer + // sometimes detached buffers have bytelength > 0 + // if we run into this case, we'll just attempt to transfer the buffer // might get a DataCloneError if user is switching colors too fast arrayBuffers.push(buffer); } From 4f76488631edecb8dc8b210ff4a59646bfdb5c06 Mon Sep 17 00:00:00 2001 From: Sashank Aryal Date: Thu, 12 Dec 2024 15:55:43 -0600 Subject: [PATCH 038/104] cleanup overlays in the worker listener callback instead --- app/packages/looker/src/lookers/abstract.ts | 13 +++++++++---- app/packages/looker/src/overlays/base.ts | 1 - app/packages/looker/src/overlays/detection.ts | 12 ++---------- app/packages/looker/src/overlays/heatmap.ts | 12 ++---------- app/packages/looker/src/overlays/segmentation.ts | 12 ++---------- .../looker/src/worker/disk-overlay-decoder.ts | 11 ++++++++--- 6 files changed, 23 insertions(+), 38 deletions(-) diff --git a/app/packages/looker/src/lookers/abstract.ts b/app/packages/looker/src/lookers/abstract.ts index 499e2634a5..1043c4d7b8 100644 --- a/app/packages/looker/src/lookers/abstract.ts +++ b/app/packages/looker/src/lookers/abstract.ts @@ -520,10 +520,6 @@ export abstract class AbstractLooker< const arrayBuffers: ArrayBuffer[] = []; for (const overlay of this.pluckedOverlays ?? []) { - // we paint overlays again, so cleanup the old ones - // this helps prevent memory leaks from, for instance, dangling ImageBitmaps - overlay.cleanup(); - let overlayData: LabelMask = null; if ("mask" in overlay.label) { @@ -739,6 +735,12 @@ export abstract class AbstractLooker< ); } + protected cleanOverlays() { + for (const overlay of this.sampleOverlays ?? []) { + overlay.cleanup(); + } + } + private loadSample(sample: Sample, transfer: Transferable[] = []) { const messageUUID = uuid(); @@ -746,6 +748,9 @@ export abstract class AbstractLooker< const listener = ({ data: { sample, coloring, uuid } }) => { if (uuid === messageUUID) { + // we paint overlays again, so cleanup the old ones + // this helps prevent memory leaks from, for instance, dangling ImageBitmaps + this.cleanOverlays(); this.sample = sample; this.state.options.coloring = coloring; this.loadOverlays(sample); diff --git a/app/packages/looker/src/overlays/base.ts b/app/packages/looker/src/overlays/base.ts index 9b433e9400..faf6f284b5 100644 --- a/app/packages/looker/src/overlays/base.ts +++ b/app/packages/looker/src/overlays/base.ts @@ -42,7 +42,6 @@ export interface SelectData { export type LabelMask = { bitmap?: ImageBitmap; - closedBitmapDims?: { width: number; height: number }; data?: OverlayMask; }; diff --git a/app/packages/looker/src/overlays/detection.ts b/app/packages/looker/src/overlays/detection.ts index d5b80d9873..1298cf2fbc 100644 --- a/app/packages/looker/src/overlays/detection.ts +++ b/app/packages/looker/src/overlays/detection.ts @@ -262,16 +262,8 @@ export default class DetectionOverlay< } public cleanup(): void { - if (this.label.mask?.bitmap) { - // store height and width in bitmap object since it might be used again - const height = this.label.mask.bitmap.height; - const width = this.label.mask.bitmap.width; - - this.label.mask?.bitmap.close(); - this.label.mask.bitmap = null; - - this.label.mask.closedBitmapDims = { width, height }; - } + this.label.mask?.bitmap?.close(); + this.label.mask.bitmap = null; } } diff --git a/app/packages/looker/src/overlays/heatmap.ts b/app/packages/looker/src/overlays/heatmap.ts index c1be209ed5..b9e41e1a9c 100644 --- a/app/packages/looker/src/overlays/heatmap.ts +++ b/app/packages/looker/src/overlays/heatmap.ts @@ -207,16 +207,8 @@ export default class HeatmapOverlay } public cleanup(): void { - if (this.label.map?.bitmap) { - // store height and width in bitmap object since it might be used again - const height = this.label.map.bitmap.height; - const width = this.label.map.bitmap.width; - - this.label.map?.bitmap.close(); - this.label.map.bitmap = null; - - this.label.map.closedBitmapDims = { width, height }; - } + this.label.map?.bitmap?.close(); + this.label.map.bitmap = null; } } diff --git a/app/packages/looker/src/overlays/segmentation.ts b/app/packages/looker/src/overlays/segmentation.ts index 3218db80a6..566a7153b0 100644 --- a/app/packages/looker/src/overlays/segmentation.ts +++ b/app/packages/looker/src/overlays/segmentation.ts @@ -262,16 +262,8 @@ export default class SegmentationOverlay } public cleanup(): void { - if (this.label.mask?.bitmap) { - // store height and width in bitmap object since it might be used again - const height = this.label.mask.bitmap.height; - const width = this.label.mask.bitmap.width; - - this.label.mask?.bitmap.close(); - this.label.mask.bitmap = null; - - this.label.mask.closedBitmapDims = { width, height }; - } + this.label.mask?.bitmap?.close(); + this.label.mask.bitmap = null; } } diff --git a/app/packages/looker/src/worker/disk-overlay-decoder.ts b/app/packages/looker/src/worker/disk-overlay-decoder.ts index e2cbf6081b..c5ac65acd1 100644 --- a/app/packages/looker/src/worker/disk-overlay-decoder.ts +++ b/app/packages/looker/src/worker/disk-overlay-decoder.ts @@ -56,11 +56,16 @@ export const decodeOverlayOnDisk = async ( // it's possible we're just re-coloring, in which case re-init mask image and set bitmap to null if ( label[overlayField] && - label[overlayField].closedBitmapDims && + label[overlayField].bitmap && !label[overlayField].image ) { - const height = label[overlayField].closedBitmapDims.height; - const width = label[overlayField].closedBitmapDims.width; + const height = label[overlayField].bitmap.height; + const width = label[overlayField].bitmap.width; + + // close the copied bitmap + label[overlayField].bitmap.close(); + label[overlayField].bitmap = null; + label[overlayField].image = new ArrayBuffer(height * width * 4); label[overlayField].bitmap.close(); label[overlayField].bitmap = null; From 1510b47fccab0aac3512026982559fc5ecca63d8 Mon Sep 17 00:00:00 2001 From: Sashank Aryal Date: Thu, 12 Dec 2024 16:43:42 -0600 Subject: [PATCH 039/104] remove bitmap = null (obj closed for modification) --- app/packages/looker/src/overlays/detection.ts | 1 - app/packages/looker/src/overlays/heatmap.ts | 1 - app/packages/looker/src/overlays/segmentation.ts | 1 - 3 files changed, 3 deletions(-) diff --git a/app/packages/looker/src/overlays/detection.ts b/app/packages/looker/src/overlays/detection.ts index 1298cf2fbc..e8616f71b1 100644 --- a/app/packages/looker/src/overlays/detection.ts +++ b/app/packages/looker/src/overlays/detection.ts @@ -263,7 +263,6 @@ export default class DetectionOverlay< public cleanup(): void { this.label.mask?.bitmap?.close(); - this.label.mask.bitmap = null; } } diff --git a/app/packages/looker/src/overlays/heatmap.ts b/app/packages/looker/src/overlays/heatmap.ts index b9e41e1a9c..d8fb8909d5 100644 --- a/app/packages/looker/src/overlays/heatmap.ts +++ b/app/packages/looker/src/overlays/heatmap.ts @@ -208,7 +208,6 @@ export default class HeatmapOverlay public cleanup(): void { this.label.map?.bitmap?.close(); - this.label.map.bitmap = null; } } diff --git a/app/packages/looker/src/overlays/segmentation.ts b/app/packages/looker/src/overlays/segmentation.ts index 566a7153b0..04c2fc693b 100644 --- a/app/packages/looker/src/overlays/segmentation.ts +++ b/app/packages/looker/src/overlays/segmentation.ts @@ -263,7 +263,6 @@ export default class SegmentationOverlay public cleanup(): void { this.label.mask?.bitmap?.close(); - this.label.mask.bitmap = null; } } From e22b56ec942eee32064a49ac1ac74fa734fe36f5 Mon Sep 17 00:00:00 2001 From: Sashank Aryal Date: Thu, 12 Dec 2024 16:50:55 -0600 Subject: [PATCH 040/104] remove unnecessary sample null check guard --- app/packages/looker/src/worker/index.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/packages/looker/src/worker/index.ts b/app/packages/looker/src/worker/index.ts index 3f1e6cefeb..dcf0b2e79b 100644 --- a/app/packages/looker/src/worker/index.ts +++ b/app/packages/looker/src/worker/index.ts @@ -327,10 +327,6 @@ const processSample = async ({ labelTagColors, schema, }: ProcessSample) => { - if (!sample) { - return; - } - mapId(sample); const imageBitmapPromises: Promise[] = []; From 73da3ae62932b9b09189aee2f1e59d827662dd7b Mon Sep 17 00:00:00 2001 From: Benjamin Kane Date: Thu, 12 Dec 2024 18:14:41 -0500 Subject: [PATCH 041/104] use buffers for hasFrame (#5264) --- app/packages/looker/src/lookers/utils.test.ts | 19 +++++++++++++++++++ app/packages/looker/src/lookers/utils.ts | 7 +++++++ app/packages/looker/src/lookers/video.ts | 9 ++------- 3 files changed, 28 insertions(+), 7 deletions(-) create mode 100644 app/packages/looker/src/lookers/utils.test.ts create mode 100644 app/packages/looker/src/lookers/utils.ts diff --git a/app/packages/looker/src/lookers/utils.test.ts b/app/packages/looker/src/lookers/utils.test.ts new file mode 100644 index 0000000000..6c0d307e6e --- /dev/null +++ b/app/packages/looker/src/lookers/utils.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import type { Buffers } from "../state"; +import { hasFrame } from "./utils"; + +describe("looker utilities", () => { + it("determines frame availability given a buffer list", () => { + const BUFFERS: Buffers = [ + [1, 3], + [5, 25], + ]; + for (const frameNumber of [1, 10, 25]) { + expect(hasFrame(BUFFERS, frameNumber)).toBe(true); + } + + for (const frameNumber of [0, 4, 26]) { + expect(hasFrame(BUFFERS, frameNumber)).toBe(false); + } + }); +}); diff --git a/app/packages/looker/src/lookers/utils.ts b/app/packages/looker/src/lookers/utils.ts new file mode 100644 index 0000000000..ea645401f0 --- /dev/null +++ b/app/packages/looker/src/lookers/utils.ts @@ -0,0 +1,7 @@ +import type { Buffers } from "../state"; + +export const hasFrame = (buffers: Buffers, frameNumber: number) => { + return buffers.some( + ([start, end]) => start <= frameNumber && frameNumber <= end + ); +}; diff --git a/app/packages/looker/src/lookers/video.ts b/app/packages/looker/src/lookers/video.ts index 24ab04feb0..2fe24f7fab 100644 --- a/app/packages/looker/src/lookers/video.ts +++ b/app/packages/looker/src/lookers/video.ts @@ -19,6 +19,7 @@ import { addToBuffers, removeFromBuffers } from "../util"; import { AbstractLooker } from "./abstract"; import { type Frame, acquireReader, clearReader } from "./frame-reader"; import { LookerUtils, withFrames } from "./shared"; +import { hasFrame } from "./utils"; let LOOKER_WITH_READER: VideoLooker | null = null; @@ -394,13 +395,7 @@ export class VideoLooker extends AbstractLooker { } private hasFrame(frameNumber: number) { - if (frameNumber === this.firstFrameNumber) { - return this.firstFrame; - } - return ( - this.frames.has(frameNumber) && - this.frames.get(frameNumber)?.deref() !== undefined - ); + return hasFrame(this.state.buffers, frameNumber); } private getFrame(frameNumber: number) { From b0388a2f463f9bcdabc9c048c4c42b51c98d5c69 Mon Sep 17 00:00:00 2001 From: Sashank Aryal Date: Thu, 12 Dec 2024 17:54:42 -0600 Subject: [PATCH 042/104] use heuristic for detecting grayscale images --- .../looker/src/worker/canvas-decoder.test.ts | 36 +++++++++++++ .../looker/src/worker/canvas-decoder.ts | 50 +++++++++++++------ 2 files changed, 71 insertions(+), 15 deletions(-) create mode 100644 app/packages/looker/src/worker/canvas-decoder.test.ts diff --git a/app/packages/looker/src/worker/canvas-decoder.test.ts b/app/packages/looker/src/worker/canvas-decoder.test.ts new file mode 100644 index 0000000000..57ce3de37b --- /dev/null +++ b/app/packages/looker/src/worker/canvas-decoder.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { isGrayscale } from "./canvas-decoder"; + +const createData = ( + pixels: Array<[number, number, number, number]> +): Uint8ClampedArray => { + return new Uint8ClampedArray(pixels.flat()); +}; + +describe("isGrayscale", () => { + it("should return true for a perfectly grayscale image", () => { + // all pixels are (100, 100, 100, 255) + const data = createData(Array(100).fill([100, 100, 100, 255])); + expect(isGrayscale(data)).toBe(true); + }); + + it("should return false if alpha is not 255", () => { + // one pixel with alpha < 255 + const data = createData([ + [100, 100, 100, 255], + [100, 100, 100, 254], + ...Array(98).fill([100, 100, 100, 255]), + ]); + expect(isGrayscale(data)).toBe(false); + }); + + it("should return false if any pixel is not grayscale", () => { + // one pixel differs in g channel + const data = createData([ + [100, 100, 100, 255], + [100, 101, 100, 255], + ...Array(98).fill([100, 100, 100, 255]), + ]); + expect(isGrayscale(data)).toBe(false); + }); +}); diff --git a/app/packages/looker/src/worker/canvas-decoder.ts b/app/packages/looker/src/worker/canvas-decoder.ts index a394554b74..c69da17500 100644 --- a/app/packages/looker/src/worker/canvas-decoder.ts +++ b/app/packages/looker/src/worker/canvas-decoder.ts @@ -1,5 +1,26 @@ import { OverlayMask } from "../numpy"; +/** + * Checks if the given pixel data is grayscale by sampling a subset of pixels. + * If the image is grayscale, the R, G, and B channels will be equal for CHECKS iteration, + * and the alpha channel will always be 255. + * + * Note: this is a very useful heuristic but still doesn't guarantee accuracy. + */ +export const isGrayscale = (data: Uint8ClampedArray, checks = 500): boolean => { + const totalPixels = data.length / 4; + const step = Math.max(1, Math.floor(totalPixels / checks)); + + for (let p = 0; p < totalPixels; p += step) { + const i = p * 4; + const [r, g, b, a] = [data[i], data[i + 1], data[i + 2], data[i + 3]]; + if (a !== 255 || r !== g || g !== b) { + return false; + } + } + return true; +}; + /** * Decodes a given image source into an OverlayMask using an OffscreenCanvas */ @@ -12,25 +33,24 @@ export const decodeWithCanvas = async (blob: ImageBitmapSource) => { const ctx = canvas.getContext("2d"); ctx.drawImage(imageBitmap, 0, 0); + imageBitmap.close(); const imageData = ctx.getImageData(0, 0, width, height); + const channels = isGrayscale(imageData.data) ? 1 : 4; - const numChannels = imageData.data.length / (width * height); - - const overlayData = { - width, - height, - data: imageData.data, - channels: numChannels, - }; - - // dispose - imageBitmap.close(); + if (channels === 1) { + // get rid of the G, B, and A channels, new buffer will be 1/4 the size + const data = new Uint8ClampedArray(width * height); + for (let i = 0; i < data.length; i++) { + data[i] = imageData.data[i * 4]; + } + imageData.data.set(data); + } return { - buffer: overlayData.data.buffer, - channels: numChannels, - arrayType: overlayData.data.constructor.name as OverlayMask["arrayType"], - shape: [overlayData.height, overlayData.width], + buffer: imageData.data.buffer, + channels, + arrayType: "Uint8ClampedArray", + shape: [height, width], } as OverlayMask; }; From e7f3eddb408c142b59233571c3a7319c6d1c0fcb Mon Sep 17 00:00:00 2001 From: topher Date: Fri, 13 Dec 2024 00:01:45 +0000 Subject: [PATCH 043/104] bump version after release branch creation --- fiftyone/constants.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/fiftyone/constants.py b/fiftyone/constants.py index 18ca73f47a..fa116a2fc0 100644 --- a/fiftyone/constants.py +++ b/fiftyone/constants.py @@ -42,7 +42,7 @@ # This setting may be ``None`` if this client has no compatibility with other # versions # -COMPATIBLE_VERSIONS = ">=0.19,<1.3" +COMPATIBLE_VERSIONS = ">=0.19,<1.4" # Package metadata _META = metadata("fiftyone") diff --git a/setup.py b/setup.py index 1009d750c3..544099b830 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ from setuptools import setup, find_packages -VERSION = "1.2.0" +VERSION = "1.3.0" def get_version(): From d570937910e8136f4f965b53b27900b7dd244ac9 Mon Sep 17 00:00:00 2001 From: Sashank Aryal Date: Thu, 12 Dec 2024 18:11:45 -0600 Subject: [PATCH 044/104] add 1% min --- .../looker/src/worker/canvas-decoder.test.ts | 13 ++++++++++--- app/packages/looker/src/worker/canvas-decoder.ts | 8 ++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/app/packages/looker/src/worker/canvas-decoder.test.ts b/app/packages/looker/src/worker/canvas-decoder.test.ts index 57ce3de37b..427b3c6131 100644 --- a/app/packages/looker/src/worker/canvas-decoder.test.ts +++ b/app/packages/looker/src/worker/canvas-decoder.test.ts @@ -9,13 +9,11 @@ const createData = ( describe("isGrayscale", () => { it("should return true for a perfectly grayscale image", () => { - // all pixels are (100, 100, 100, 255) const data = createData(Array(100).fill([100, 100, 100, 255])); expect(isGrayscale(data)).toBe(true); }); it("should return false if alpha is not 255", () => { - // one pixel with alpha < 255 const data = createData([ [100, 100, 100, 255], [100, 100, 100, 254], @@ -25,7 +23,6 @@ describe("isGrayscale", () => { }); it("should return false if any pixel is not grayscale", () => { - // one pixel differs in g channel const data = createData([ [100, 100, 100, 255], [100, 101, 100, 255], @@ -33,4 +30,14 @@ describe("isGrayscale", () => { ]); expect(isGrayscale(data)).toBe(false); }); + + it("should detect a non-grayscale pixel placed deep enough to ensure at least 1% of pixels are checked", () => { + // large image: 100,000 pixels. 1% of 100,000 is 1,000. + // the function will check at least 1,000 pixels. + // place a non-grayscale pixel after 800 pixels. + const pixels = Array(100000).fill([50, 50, 50, 255]); + pixels[800] = [50, 51, 50, 255]; // this is within the first 1% of pixels + const data = createData(pixels); + expect(isGrayscale(data)).toBe(false); + }); }); diff --git a/app/packages/looker/src/worker/canvas-decoder.ts b/app/packages/looker/src/worker/canvas-decoder.ts index c69da17500..52d01b5d7b 100644 --- a/app/packages/looker/src/worker/canvas-decoder.ts +++ b/app/packages/looker/src/worker/canvas-decoder.ts @@ -2,13 +2,13 @@ import { OverlayMask } from "../numpy"; /** * Checks if the given pixel data is grayscale by sampling a subset of pixels. - * If the image is grayscale, the R, G, and B channels will be equal for CHECKS iteration, + * The function will check at least 500 pixels or 1% of all pixels, whichever is larger. + * If the image is grayscale, the R, G, and B channels will be equal for all sampled pixels, * and the alpha channel will always be 255. - * - * Note: this is a very useful heuristic but still doesn't guarantee accuracy. */ -export const isGrayscale = (data: Uint8ClampedArray, checks = 500): boolean => { +export const isGrayscale = (data: Uint8ClampedArray): boolean => { const totalPixels = data.length / 4; + const checks = Math.max(500, Math.floor(totalPixels * 0.01)); const step = Math.max(1, Math.floor(totalPixels / checks)); for (let p = 0; p < totalPixels; p += step) { From e48511db7b6ca0b4b18827e862c863445ca38957 Mon Sep 17 00:00:00 2001 From: Sashank Aryal Date: Thu, 12 Dec 2024 18:13:13 -0600 Subject: [PATCH 045/104] add clarifying comments --- app/packages/looker/src/worker/canvas-decoder.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/packages/looker/src/worker/canvas-decoder.ts b/app/packages/looker/src/worker/canvas-decoder.ts index 52d01b5d7b..390ace2a04 100644 --- a/app/packages/looker/src/worker/canvas-decoder.ts +++ b/app/packages/looker/src/worker/canvas-decoder.ts @@ -36,6 +36,8 @@ export const decodeWithCanvas = async (blob: ImageBitmapSource) => { imageBitmap.close(); const imageData = ctx.getImageData(0, 0, width, height); + + // for nongrayscale images, channel is guaranteed to be 4 (RGBA) const channels = isGrayscale(imageData.data) ? 1 : 4; if (channels === 1) { From d83c00ab0d4590c2a0541a85558a104a35cdcd35 Mon Sep 17 00:00:00 2001 From: Sashank Aryal Date: Thu, 12 Dec 2024 19:41:45 -0600 Subject: [PATCH 046/104] fix rgb mask recoloring bug --- app/packages/looker/src/worker/painter.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/packages/looker/src/worker/painter.ts b/app/packages/looker/src/worker/painter.ts index 2e9f5a3ea3..6730d90cac 100644 --- a/app/packages/looker/src/worker/painter.ts +++ b/app/packages/looker/src/worker/painter.ts @@ -278,7 +278,14 @@ export const PainterFactory = (requestColor) => ({ const isRgbMaskTargets_ = isRgbMaskTargets(maskTargets); - if (maskData.channels > 2) { + // we have an additional guard for targets length = new image buffer byte length + // because we reduce the RGBA mask into a grayscale mask in first load for + // performance reasons + // For subsequent mask updates, the maskData.buffer is already a single channel + if ( + maskData.channels === 4 && + targets.length === label.mask.image.byteLength + ) { for (let i = 0; i < overlay.length; i++) { const [r, g, b] = getRgbFromMaskData(targets, maskData.channels, i); From 686be45e78255804c3a2ad7c42c24b13f82b9ece Mon Sep 17 00:00:00 2001 From: brimoor Date: Fri, 13 Dec 2024 01:15:18 -0500 Subject: [PATCH 047/104] fix #5254 --- .../panels/model_evaluation/__init__.py | 58 ++++++++++--------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/fiftyone/operators/builtins/panels/model_evaluation/__init__.py b/fiftyone/operators/builtins/panels/model_evaluation/__init__.py index cb33082f9d..b91efbe01c 100644 --- a/fiftyone/operators/builtins/panels/model_evaluation/__init__.py +++ b/fiftyone/operators/builtins/panels/model_evaluation/__init__.py @@ -5,16 +5,17 @@ | `voxel51.com `_ | """ - +from collections import defaultdict, Counter import os import traceback -import fiftyone.operators.types as types -from collections import defaultdict, Counter +import numpy as np + from fiftyone import ViewField as F from fiftyone.operators.categories import Categories from fiftyone.operators.panel import Panel, PanelConfig from fiftyone.core.plots.plotly import _to_log_colorscale +import fiftyone.operators.types as types STORE_NAME = "model_evaluation_panel_builtin" @@ -104,29 +105,32 @@ def get_avg_confidence(self, per_class_metrics): total += metrics["confidence"] return total / count if count > 0 else None - def get_tp_fp_fn(self, ctx): - view_state = ctx.panel.get_state("view") or {} - key = view_state.get("key") - dataset = ctx.dataset - tp_key = f"{key}_tp" - fp_key = f"{key}_fp" - fn_key = f"{key}_fn" - tp_total = ( - sum(ctx.dataset.values(tp_key)) - if dataset.has_field(tp_key) - else None - ) - fp_total = ( - sum(ctx.dataset.values(fp_key)) - if dataset.has_field(fp_key) - else None - ) - fn_total = ( - sum(ctx.dataset.values(fn_key)) - if dataset.has_field(fn_key) - else None - ) - return tp_total, fp_total, fn_total + def get_tp_fp_fn(self, info, results): + # Binary classification + if ( + info.config.type == "classification" + and info.config.method == "binary" + ): + neg_label, pos_label = results.classes + tp_count = np.count_nonzero( + (results.ytrue == pos_label) & (results.ypred == pos_label) + ) + fp_count = np.count_nonzero( + (results.ytrue != pos_label) & (results.ypred == pos_label) + ) + fn_count = np.count_nonzero( + (results.ytrue == pos_label) & (results.ypred != pos_label) + ) + return tp_count, fp_count, fn_count + + # Object detection + if info.config.type == "detection": + tp_count = np.count_nonzero(results.ytrue == results.ypred) + fp_count = np.count_nonzero(results.ytrue == results.missing) + fn_count = np.count_nonzero(results.ypred == results.missing) + return tp_count, fp_count, fn_count + + return None, None, None def get_map(self, results): try: @@ -298,7 +302,7 @@ def load_evaluation(self, ctx): per_class_metrics ) metrics["tp"], metrics["fp"], metrics["fn"] = self.get_tp_fp_fn( - ctx + info, results ) metrics["mAP"] = self.get_map(results) evaluation_data = { From 305377967ab16fde74febb01562cc437320d5ff7 Mon Sep 17 00:00:00 2001 From: Justin Newberry Date: Fri, 13 Dec 2024 10:32:29 -0500 Subject: [PATCH 048/104] Sort Shuffle Stage in FfityOne App (#5270) * sort * also here --------- Co-authored-by: Justin Newberry --- fiftyone/__public__.py | 2 +- fiftyone/core/stages.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/fiftyone/__public__.py b/fiftyone/__public__.py index f343bdf718..1ccd9d15a5 100644 --- a/fiftyone/__public__.py +++ b/fiftyone/__public__.py @@ -215,7 +215,6 @@ MatchLabels, MatchTags, Mongo, - Shuffle, Select, SelectBy, SelectFields, @@ -224,6 +223,7 @@ SelectGroupSlices, SelectLabels, SetField, + Shuffle, Skip, SortBy, SortBySimilarity, diff --git a/fiftyone/core/stages.py b/fiftyone/core/stages.py index eb29d5a942..a71272bf15 100644 --- a/fiftyone/core/stages.py +++ b/fiftyone/core/stages.py @@ -8628,7 +8628,6 @@ def repr_ViewExpression(self, expr, level): MatchLabels, MatchTags, Mongo, - Shuffle, Select, SelectBy, SelectFields, @@ -8637,6 +8636,7 @@ def repr_ViewExpression(self, expr, level): SelectGroupSlices, SelectLabels, SetField, + Shuffle, Skip, SortBy, SortBySimilarity, From 79b83950344bad5bf43455be50ba31c006db9a9e Mon Sep 17 00:00:00 2001 From: afoley587 <54959686+afoley587@users.noreply.github.com> Date: Fri, 13 Dec 2024 11:12:25 -0500 Subject: [PATCH 049/104] fix(ci): AS-359 Update Ubuntu24 Binaries For MongoDB (#5269) --- .github/workflows/test.yml | 2 +- package/db/setup.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e21ef73509..c85454979b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,7 +4,7 @@ on: workflow_call jobs: test-app: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 diff --git a/package/db/setup.py b/package/db/setup.py index 63c5ac5dfe..ccf07ba9ef 100644 --- a/package/db/setup.py +++ b/package/db/setup.py @@ -124,8 +124,8 @@ "x86_64": "https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-ubuntu2204-7.0.4.tgz", }, "24": { - "aarch64": "https://fastdl.mongodb.org/linux/mongodb-linux-aarch64-ubuntu2204-7.0.4.tgz", - "x86_64": "https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-ubuntu2204-7.0.4.tgz", + "aarch64": "https://fastdl.mongodb.org/linux/mongodb-linux-aarch64-ubuntu2404-8.0.4.tgz", + "x86_64": "https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-ubuntu2404-8.0.4.tgz", }, }, } @@ -175,7 +175,7 @@ def _get_download(): MONGODB_BINARIES = ["mongod"] -VERSION = "1.1.7" +VERSION = "1.2.0" def get_version(): From 8da1243d81384d0e74adda97e2a64d25055c18fe Mon Sep 17 00:00:00 2001 From: topher Date: Fri, 13 Dec 2024 11:49:47 -0500 Subject: [PATCH 050/104] Sort Shuffle Stage in FfityOne App (#5270) (#5272) * sort * also here --------- Co-authored-by: Justin Newberry Co-authored-by: Justin Newberry --- fiftyone/__public__.py | 2 +- fiftyone/core/stages.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/fiftyone/__public__.py b/fiftyone/__public__.py index f343bdf718..1ccd9d15a5 100644 --- a/fiftyone/__public__.py +++ b/fiftyone/__public__.py @@ -215,7 +215,6 @@ MatchLabels, MatchTags, Mongo, - Shuffle, Select, SelectBy, SelectFields, @@ -224,6 +223,7 @@ SelectGroupSlices, SelectLabels, SetField, + Shuffle, Skip, SortBy, SortBySimilarity, diff --git a/fiftyone/core/stages.py b/fiftyone/core/stages.py index eb29d5a942..a71272bf15 100644 --- a/fiftyone/core/stages.py +++ b/fiftyone/core/stages.py @@ -8628,7 +8628,6 @@ def repr_ViewExpression(self, expr, level): MatchLabels, MatchTags, Mongo, - Shuffle, Select, SelectBy, SelectFields, @@ -8637,6 +8636,7 @@ def repr_ViewExpression(self, expr, level): SelectGroupSlices, SelectLabels, SetField, + Shuffle, Skip, SortBy, SortBySimilarity, From d41fffce5d5ba8a34361bf3de2f3b5ad238eca8a Mon Sep 17 00:00:00 2001 From: imanjra Date: Fri, 13 Dec 2024 14:51:13 -0500 Subject: [PATCH 051/104] TP/FP/NP support for binary classification model evaluation --- .../NativeModelEvaluationView/Evaluation.tsx | 10 +++++--- .../panels/model_evaluation/__init__.py | 25 +++++++++++++------ 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx index d7f932b23f..c3ee377dab 100644 --- a/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx +++ b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx @@ -166,6 +166,7 @@ export default function Evaluation(props: EvaluationProps) { const evaluationConfig = evaluationInfo.config; const evaluationMetrics = evaluation.metrics; const evaluationType = evaluationConfig.type; + const evaluationMethod = evaluationConfig.method; const compareEvaluationInfo = compareEvaluation?.info || {}; const compareEvaluationKey = compareEvaluationInfo?.key; const compareEvaluationTimestamp = compareEvaluationInfo?.timestamp; @@ -174,6 +175,9 @@ export default function Evaluation(props: EvaluationProps) { const compareEvaluationType = compareEvaluationConfig.type; const isObjectDetection = evaluationType === "detection"; const isSegmentation = evaluationType === "segmentation"; + const isBinaryClassification = + evaluationType === "classification" && evaluationMethod === "binary"; + const showTpFpFn = isObjectDetection || isBinaryClassification; const infoRows = [ { id: "evaluation_key", @@ -385,7 +389,7 @@ export default function Evaluation(props: EvaluationProps) { ? "compare" : "selected" : false, - hide: !isObjectDetection, + hide: !showTpFpFn, }, { id: "fp", @@ -400,7 +404,7 @@ export default function Evaluation(props: EvaluationProps) { ? "compare" : "selected" : false, - hide: !isObjectDetection, + hide: !showTpFpFn, }, { id: "fn", @@ -415,7 +419,7 @@ export default function Evaluation(props: EvaluationProps) { ? "compare" : "selected" : false, - hide: !isObjectDetection, + hide: !showTpFpFn, }, ]; diff --git a/fiftyone/operators/builtins/panels/model_evaluation/__init__.py b/fiftyone/operators/builtins/panels/model_evaluation/__init__.py index b91efbe01c..e8a1aff301 100644 --- a/fiftyone/operators/builtins/panels/model_evaluation/__init__.py +++ b/fiftyone/operators/builtins/panels/model_evaluation/__init__.py @@ -5,6 +5,7 @@ | `voxel51.com `_ | """ + from collections import defaultdict, Counter import os import traceback @@ -96,6 +97,12 @@ def on_load(self, ctx): ctx.panel.set_data("permissions", permissions) self.load_pending_evaluations(ctx) + def is_binary_classification(self, info): + return ( + info.config.type == "classification" + and info.config.method == "binary" + ) + def get_avg_confidence(self, per_class_metrics): count = 0 total = 0 @@ -107,10 +114,7 @@ def get_avg_confidence(self, per_class_metrics): def get_tp_fp_fn(self, info, results): # Binary classification - if ( - info.config.type == "classification" - and info.config.method == "binary" - ): + if self.is_binary_classification(info): neg_label, pos_label = results.classes tp_count = np.count_nonzero( (results.ytrue == pos_label) & (results.ypred == pos_label) @@ -422,10 +426,15 @@ def load_view(self, ctx): gt_field, F("label") == y ).filter_labels(pred_field, F("label") == x) elif view_type == "field": - view = ctx.dataset.filter_labels( - pred_field, F(computed_eval_key) == field - ) - + if self.is_binary_classification(info): + uppercase_field = field.upper() + view = ctx.dataset.match( + {computed_eval_key: {"$eq": uppercase_field}} + ) + else: + view = ctx.dataset.filter_labels( + pred_field, F(computed_eval_key) == field + ) if view is not None: ctx.ops.set_view(view) From b2734ab695e94bf42bf226ae7dfa4c9a7ebe1378 Mon Sep 17 00:00:00 2001 From: prerna <163362853+prernadh@users.noreply.github.com> Date: Sat, 14 Dec 2024 02:27:22 +0530 Subject: [PATCH 052/104] Capturing error in loading a specific config class better and bumping log level (#5213) * Capturing the error better and bumping log level * Updating error message --- fiftyone/core/runs.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/fiftyone/core/runs.py b/fiftyone/core/runs.py index 442588798c..f9ecff22a1 100644 --- a/fiftyone/core/runs.py +++ b/fiftyone/core/runs.py @@ -135,9 +135,10 @@ def from_dict(cls, d): try: config_cls = etau.get_class(config_cls) - except: - logger.debug( - "Unable to load '%s'; falling back to base class", config_cls + except Exception as e: + logger.warning( + f"Unable to load {config_cls}; falling back to base class", + exc_info=True, ) config_cls = cls.base_config_cls(type) From 981c42df845e46492e960a0a5b9ab91d196f5688 Mon Sep 17 00:00:00 2001 From: brimoor Date: Fri, 13 Dec 2024 01:17:03 -0500 Subject: [PATCH 053/104] include all labels in views --- .../panels/model_evaluation/__init__.py | 97 +++++++++++++++---- 1 file changed, 76 insertions(+), 21 deletions(-) diff --git a/fiftyone/operators/builtins/panels/model_evaluation/__init__.py b/fiftyone/operators/builtins/panels/model_evaluation/__init__.py index e8a1aff301..807c446cd5 100644 --- a/fiftyone/operators/builtins/panels/model_evaluation/__init__.py +++ b/fiftyone/operators/builtins/panels/model_evaluation/__init__.py @@ -97,12 +97,6 @@ def on_load(self, ctx): ctx.panel.set_data("permissions", permissions) self.load_pending_evaluations(ctx) - def is_binary_classification(self, info): - return ( - info.config.type == "classification" - and info.config.method == "binary" - ) - def get_avg_confidence(self, per_class_metrics): count = 0 total = 0 @@ -114,7 +108,10 @@ def get_avg_confidence(self, per_class_metrics): def get_tp_fp_fn(self, info, results): # Binary classification - if self.is_binary_classification(info): + if ( + info.config.type == "classification" + and info.config.method == "binary" + ): neg_label, pos_label = results.classes tp_count = np.count_nonzero( (results.ytrue == pos_label) & (results.ypred == pos_label) @@ -418,23 +415,81 @@ def load_view(self, ctx): y = view_options.get("y", None) field = view_options.get("field", None) computed_eval_key = view_options.get("key", eval_key) + eval_view = ctx.dataset.load_evaluation_view(eval_key) + view = None - if view_type == "class": - view = ctx.dataset.filter_labels(pred_field, F("label") == x) - elif view_type == "matrix": - view = ctx.dataset.filter_labels( - gt_field, F("label") == y - ).filter_labels(pred_field, F("label") == x) - elif view_type == "field": - if self.is_binary_classification(info): - uppercase_field = field.upper() - view = ctx.dataset.match( - {computed_eval_key: {"$eq": uppercase_field}} + if info.config.type == "classification": + if view_type == "class": + view = eval_view.match( + (F(f"{gt_field}.label") == x) + | (F(f"{pred_field}.label") == x) ) - else: - view = ctx.dataset.filter_labels( - pred_field, F(computed_eval_key) == field + elif view_type == "matrix": + view = eval_view.match( + (F(f"{gt_field}.label") == y) + & (F(f"{pred_field}.label") == x) + ) + elif view_type == "field": + if field == "fn": + view = eval_view.match( + F(f"{gt_field}.{computed_eval_key}") == field + ) + else: + view = eval_view.match( + F(f"{pred_field}.{computed_eval_key}") == field + ) + elif info.config.type == "detection": + _, pred_root = ctx.dataset._get_label_field_path(pred_field) + _, gt_root = ctx.dataset._get_label_field_path(gt_field) + + if view_type == "class": + view = ( + eval_view.filter_labels( + pred_field, F("label") == x, only_matches=False + ) + .filter_labels( + gt_field, F("label") == x, only_matches=False + ) + .match( + (F(pred_root).length() > 0) | (F(gt_root).length() > 0) + ) ) + elif view_type == "matrix": + view = ( + eval_view.filter_labels( + gt_field, F("label") == y, only_matches=False + ) + .filter_labels( + pred_field, F("label") == x, only_matches=False + ) + .match( + (F(pred_root).length() > 0) & (F(gt_root).length() > 0) + ) + ) + elif view_type == "field": + if field == "tp": + view = eval_view.filter_labels( + gt_field, + F(computed_eval_key) == field, + only_matches=False, + ).filter_labels( + pred_field, + F(computed_eval_key) == field, + only_matches=True, + ) + elif field == "fn": + view = eval_view.filter_labels( + gt_field, + F(computed_eval_key) == field, + only_matches=True, + ) + else: + view = eval_view.filter_labels( + pred_field, + F(computed_eval_key) == field, + only_matches=True, + ) + if view is not None: ctx.ops.set_view(view) From a9ea1c3f1a31031d567cd4520e8ae27e60285bec Mon Sep 17 00:00:00 2001 From: brimoor Date: Sat, 14 Dec 2024 22:45:14 -0500 Subject: [PATCH 054/104] filtering comparison field as well --- .../panels/model_evaluation/__init__.py | 135 +++++++++++------- 1 file changed, 87 insertions(+), 48 deletions(-) diff --git a/fiftyone/operators/builtins/panels/model_evaluation/__init__.py b/fiftyone/operators/builtins/panels/model_evaluation/__init__.py index 807c446cd5..96684ce080 100644 --- a/fiftyone/operators/builtins/panels/model_evaluation/__init__.py +++ b/fiftyone/operators/builtins/panels/model_evaluation/__init__.py @@ -312,6 +312,8 @@ def load_evaluation(self, ctx): "confusion_matrices": self.get_confusion_matrices(results), "per_class_metrics": per_class_metrics, } + ctx.panel.set_state("missing", results.missing) + if ENABLE_CACHING: # Cache the evaluation data try: @@ -406,88 +408,125 @@ def load_view(self, ctx): return view_state = ctx.panel.get_state("view") or {} + view_options = ctx.params.get("options", {}) + eval_key = view_state.get("key") + eval_key = view_options.get("key", eval_key) + eval_view = ctx.dataset.load_evaluation_view(eval_key) info = ctx.dataset.get_evaluation_info(eval_key) pred_field = info.config.pred_field gt_field = info.config.gt_field - view_options = ctx.params.get("options", {}) + + eval_key2 = view_state.get("compareKey", None) + pred_field2 = None + gt_field2 = None + if eval_key2 is not None: + info2 = ctx.dataset.get_evaluation_info(eval_key2) + pred_field2 = info2.config.pred_field + if info2.config.gt_field != gt_field: + gt_field2 = info2.config.gt_field + x = view_options.get("x", None) y = view_options.get("y", None) field = view_options.get("field", None) - computed_eval_key = view_options.get("key", eval_key) - eval_view = ctx.dataset.load_evaluation_view(eval_key) + missing = ctx.panel.get_state("missing", "(none)") view = None if info.config.type == "classification": if view_type == "class": - view = eval_view.match( - (F(f"{gt_field}.label") == x) - | (F(f"{pred_field}.label") == x) - ) + # All GT/predictions of class `x` + expr = F(f"{gt_field}.label") == x + expr |= F(f"{pred_field}.label") == x + if gt_field2 is not None: + expr |= F(f"{gt_field2}.label") == x + if pred_field2 is not None: + expr |= F(f"{pred_field2}.label") == x + view = eval_view.match(expr) elif view_type == "matrix": - view = eval_view.match( - (F(f"{gt_field}.label") == y) - & (F(f"{pred_field}.label") == x) - ) + # Specific confusion matrix cell (including FP/FN) + expr = F(f"{gt_field}.label") == y + expr &= F(f"{pred_field}.label") == x + view = eval_view.match(expr) elif view_type == "field": - if field == "fn": - view = eval_view.match( - F(f"{gt_field}.{computed_eval_key}") == field - ) + if info.config.method == "binary": + # All TP/FP/FN + expr = F(f"{eval_key}") == field.upper() + view = eval_view.match(expr) else: - view = eval_view.match( - F(f"{pred_field}.{computed_eval_key}") == field - ) + # Correct/incorrect + expr = F(f"{eval_key}") == field + view = eval_view.match(expr) elif info.config.type == "detection": - _, pred_root = ctx.dataset._get_label_field_path(pred_field) _, gt_root = ctx.dataset._get_label_field_path(gt_field) + _, pred_root = ctx.dataset._get_label_field_path(pred_field) + if gt_field2 is not None: + _, gt_root2 = ctx.dataset._get_label_field_path(gt_field2) + if pred_field2 is not None: + _, pred_root2 = ctx.dataset._get_label_field_path(pred_field2) if view_type == "class": - view = ( - eval_view.filter_labels( - pred_field, F("label") == x, only_matches=False - ) - .filter_labels( - gt_field, F("label") == x, only_matches=False + # All GT/predictions of class `x` + view = eval_view.filter_labels( + gt_field, F("label") == x, only_matches=False + ) + expr = F(gt_root).length() > 0 + view = view.filter_labels( + pred_field, F("label") == x, only_matches=False + ) + expr |= F(pred_root).length() > 0 + if gt_field2 is not None: + view = view.filter_labels( + gt_field2, F("label") == x, only_matches=False ) - .match( - (F(pred_root).length() > 0) | (F(gt_root).length() > 0) + expr |= F(gt_root2).length() > 0 + if pred_field2 is not None: + view = view.filter_labels( + pred_field2, F("label") == x, only_matches=False ) - ) + expr |= F(pred_root2).length() > 0 + view = view.match(expr) elif view_type == "matrix": - view = ( - eval_view.filter_labels( + if y == missing: + # False positives of class `x` + expr = (F("label") == x) & (F(eval_key) == "fp") + view = eval_view.filter_labels( + pred_field, expr, only_matches=True + ) + elif x == missing: + # False negatives of class `y` + expr = (F("label") == y) & (F(eval_key) == "fn") + view = eval_view.filter_labels( + gt_field, expr, only_matches=True + ) + else: + # All class `y` GT and class `x` predictions in same sample + view = eval_view.filter_labels( gt_field, F("label") == y, only_matches=False ) - .filter_labels( + expr = F(gt_root).length() > 0 + view = view.filter_labels( pred_field, F("label") == x, only_matches=False ) - .match( - (F(pred_root).length() > 0) & (F(gt_root).length() > 0) - ) - ) + expr &= F(pred_root).length() > 0 + view = view.match(expr) elif view_type == "field": if field == "tp": + # All true positives view = eval_view.filter_labels( - gt_field, - F(computed_eval_key) == field, - only_matches=False, - ).filter_labels( - pred_field, - F(computed_eval_key) == field, - only_matches=True, + gt_field, F(eval_key) == field, only_matches=False + ) + view = view.filter_labels( + pred_field, F(eval_key) == field, only_matches=True ) elif field == "fn": + # All false negatives view = eval_view.filter_labels( - gt_field, - F(computed_eval_key) == field, - only_matches=True, + gt_field, F(eval_key) == field, only_matches=True ) else: + # All false positives view = eval_view.filter_labels( - pred_field, - F(computed_eval_key) == field, - only_matches=True, + pred_field, F(eval_key) == field, only_matches=True ) if view is not None: From fdf58fcadce758fa1af7298d26981e93ce0625f8 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Mon, 16 Dec 2024 14:54:16 -0700 Subject: [PATCH 055/104] initial executor test --- tests/unittests/operators/executor_tests.py | 49 +++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/unittests/operators/executor_tests.py diff --git a/tests/unittests/operators/executor_tests.py b/tests/unittests/operators/executor_tests.py new file mode 100644 index 0000000000..1958997e29 --- /dev/null +++ b/tests/unittests/operators/executor_tests.py @@ -0,0 +1,49 @@ +import pytest +from unittest.mock import MagicMock, patch +from starlette.exceptions import HTTPException + +from fiftyone.operators.operator import Operator +from fiftyone.operators.executor import ( + execute_or_delegate_operator, + ExecutionResult, + ExecutionContext, +) +from fiftyone.operators import OperatorConfig + + +class EchoOperator(Operator): + @property + def config(self): + return OperatorConfig(name="echo") + + @property + def uri(self): + return "@testing/plugin/echo" + + def execute(self, ctx): + return {"message": ctx.params.get("message", None)} + + +@pytest.mark.asyncio +@patch("fiftyone.operators.executor.OperatorRegistry") +async def test_execute_or_delegate_operator_with_global_mock( + mock_registry_cls, +): + test_op = EchoOperator() + mock_registry = MagicMock() + mock_registry.can_execute.return_value = True + mock_registry.operator_exists.return_value = True + mock_registry.get_operator.return_value = test_op + mock_registry_cls.return_value = mock_registry + + request_params = { + "dataset_name": "test_dataset", + "operator_uri": test_op.uri, + "params": {"message": "Hello, World!"}, + } + + result = await execute_or_delegate_operator(test_op.uri, request_params) + + assert isinstance(result, ExecutionResult) + json_result = result.to_json() + assert json_result["result"]["message"] == "Hello, World!" From 3fb035b401f2b462d6100bf398148e52267b0540 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Tue, 17 Dec 2024 09:39:47 -0700 Subject: [PATCH 056/104] remove executor test mocking --- tests/unittests/operators/executor_tests.py | 32 ++++++++++----------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/unittests/operators/executor_tests.py b/tests/unittests/operators/executor_tests.py index 1958997e29..ef2321a8dc 100644 --- a/tests/unittests/operators/executor_tests.py +++ b/tests/unittests/operators/executor_tests.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch from starlette.exceptions import HTTPException +import fiftyone.operators.types as types from fiftyone.operators.operator import Operator from fiftyone.operators.executor import ( execute_or_delegate_operator, @@ -9,6 +10,10 @@ ExecutionContext, ) from fiftyone.operators import OperatorConfig +import fiftyone.operators.builtin as builtin + + +ECHO_URI = "@voxel51/operators/echo" class EchoOperator(Operator): @@ -16,33 +21,28 @@ class EchoOperator(Operator): def config(self): return OperatorConfig(name="echo") - @property - def uri(self): - return "@testing/plugin/echo" + def resolve_input(self, ctx): + inputs = types.Object() + inputs.str("message") + return types.Property(inputs) def execute(self, ctx): return {"message": ctx.params.get("message", None)} -@pytest.mark.asyncio -@patch("fiftyone.operators.executor.OperatorRegistry") -async def test_execute_or_delegate_operator_with_global_mock( - mock_registry_cls, -): - test_op = EchoOperator() - mock_registry = MagicMock() - mock_registry.can_execute.return_value = True - mock_registry.operator_exists.return_value = True - mock_registry.get_operator.return_value = test_op - mock_registry_cls.return_value = mock_registry +# Force registration of the operator for testing +builtin.BUILTIN_OPERATORS.append(EchoOperator(_builtin=True)) + +@pytest.mark.asyncio +async def test_execute_or_delegate_operator(): request_params = { "dataset_name": "test_dataset", - "operator_uri": test_op.uri, + "operator_uri": ECHO_URI, "params": {"message": "Hello, World!"}, } - result = await execute_or_delegate_operator(test_op.uri, request_params) + result = await execute_or_delegate_operator(ECHO_URI, request_params) assert isinstance(result, ExecutionResult) json_result = result.to_json() From 8997a39c724d8bbf462e22986390c1d4a7c1a04f Mon Sep 17 00:00:00 2001 From: imanjra Date: Mon, 16 Dec 2024 12:25:45 -0500 Subject: [PATCH 057/104] consistent table heading row text style --- .../NativeModelEvaluationView/Evaluation.tsx | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx index c3ee377dab..d6697699ce 100644 --- a/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx +++ b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx @@ -600,14 +600,16 @@ export default function Evaluation(props: EvaluationProps) { theme.palette.text.secondary, fontSize: "1rem", fontWeight: 600, }, }} > - Metric + + Metric + {compareKey} - Difference + + Difference + )} @@ -869,14 +873,16 @@ export default function Evaluation(props: EvaluationProps) { theme.palette.text.secondary, fontSize: "1rem", fontWeight: 600, }, }} > - Metric + + Metric + {compareKey} - {" "} - Difference + + + Difference + )} @@ -1048,14 +1056,16 @@ export default function Evaluation(props: EvaluationProps) { theme.palette.text.secondary, fontSize: "1rem", fontWeight: 600, }, }} > - Metric + + Metric + {compareKey} {" "} - Difference + + Difference + )} @@ -1236,14 +1248,16 @@ export default function Evaluation(props: EvaluationProps) { theme.palette.text.secondary, fontSize: "1rem", fontWeight: 600, }, }} > - Property + + Property + Date: Mon, 16 Dec 2024 12:50:09 -0500 Subject: [PATCH 058/104] fix evaluation timestamp --- .../SchemaIO/components/NativeModelEvaluationView/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/utils.ts b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/utils.ts index 44e7ffec33..4e6ed9d4dd 100644 --- a/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/utils.ts +++ b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/utils.ts @@ -35,7 +35,7 @@ export function getNumericDifference( export function formatValue(value: string | number, fractionDigits = 3) { const numericValue = typeof value === "number" ? value : parseFloat(value as string); - if (!isNaN(numericValue)) { + if (!isNaN(numericValue) && numericValue == value) { return parseFloat(numericValue.toFixed(fractionDigits)); } return value; From 4cdeff05735b55045e089e4bd422f92a072dd87d Mon Sep 17 00:00:00 2001 From: imanjra Date: Mon, 16 Dec 2024 13:00:56 -0500 Subject: [PATCH 059/104] hide unsupported metrics in model eval panel --- .../NativeModelEvaluationView/Evaluation.tsx | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx index d6697699ce..69aa0a4b39 100644 --- a/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx +++ b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx @@ -174,6 +174,7 @@ export default function Evaluation(props: EvaluationProps) { const compareEvaluationMetrics = compareEvaluation?.metrics || {}; const compareEvaluationType = compareEvaluationConfig.type; const isObjectDetection = evaluationType === "detection"; + const isClassification = evaluationType === "classification"; const isSegmentation = evaluationType === "segmentation"; const isBinaryClassification = evaluationType === "classification" && evaluationMethod === "binary"; @@ -226,6 +227,7 @@ export default function Evaluation(props: EvaluationProps) { property: "IoU Threshold", value: evaluationConfig.iou, compareValue: compareEvaluationConfig.iou, + hide: !isObjectDetection, }, { id: "classwise", @@ -266,6 +268,7 @@ export default function Evaluation(props: EvaluationProps) { compareValue: Array.isArray(compareEvaluationConfig.iou_threshs) ? compareEvaluationConfig.iou_threshs.join(", ") : "", + hide: !isObjectDetection, }, { id: "max_preds", @@ -299,12 +302,14 @@ export default function Evaluation(props: EvaluationProps) { property: "Average Confidence", value: evaluationMetrics.average_confidence, compareValue: compareEvaluationMetrics.average_confidence, + hide: isSegmentation, }, { id: "iou", property: "IoU Threshold", value: evaluationConfig.iou, compareValue: compareEvaluationConfig.iou, + hide: !isObjectDetection, }, { id: "precision", @@ -325,6 +330,7 @@ export default function Evaluation(props: EvaluationProps) { compareValue: compareEvaluationMetrics.fscore, }, ]; + const computedMetricPerformance = metricPerformance.filter((m) => !m.hide); const summaryRows = [ { id: "average_confidence", @@ -847,8 +853,8 @@ export default function Evaluation(props: EvaluationProps) { data={[ { histfunc: "sum", - y: metricPerformance.map((m) => m.value), - x: metricPerformance.map((m) => m.property), + y: computedMetricPerformance.map((m) => m.value), + x: computedMetricPerformance.map((m) => m.property), type: "histogram", name: name, marker: { @@ -857,8 +863,8 @@ export default function Evaluation(props: EvaluationProps) { }, { histfunc: "sum", - y: metricPerformance.map((m) => m.compareValue), - x: metricPerformance.map((m) => m.property), + y: computedMetricPerformance.map((m) => m.compareValue), + x: computedMetricPerformance.map((m) => m.property), type: "histogram", name: compareKey, marker: { @@ -913,7 +919,7 @@ export default function Evaluation(props: EvaluationProps) { - {metricPerformance.map((row) => ( + {computedMetricPerformance.map((row) => ( {row.property} @@ -1283,17 +1289,19 @@ export default function Evaluation(props: EvaluationProps) { - {infoRows.map((row) => ( - - - {row.property} - - {formatValue(row.value)} - {compareKey && ( - {formatValue(row.compareValue)} - )} - - ))} + {infoRows.map((row) => + row.hide ? null : ( + + + {row.property} + + {formatValue(row.value)} + {compareKey && ( + {formatValue(row.compareValue)} + )} + + ) + )} From 09bb793cba9eadcd908740766e5d087f27c1e08f Mon Sep 17 00:00:00 2001 From: imanjra Date: Mon, 16 Dec 2024 14:03:40 -0500 Subject: [PATCH 060/104] gracefully handle unsupported model evaluation --- .../NativeModelEvaluationView/Error.tsx | 40 +++++++++++++++++++ .../NativeModelEvaluationView/ErrorIcon.tsx | 33 +++++++++++++++ .../NativeModelEvaluationView/Evaluation.tsx | 22 +++++++++- .../panels/model_evaluation/__init__.py | 11 ++++- 4 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Error.tsx create mode 100644 app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/ErrorIcon.tsx diff --git a/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Error.tsx b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Error.tsx new file mode 100644 index 0000000000..b04547134e --- /dev/null +++ b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Error.tsx @@ -0,0 +1,40 @@ +import { West } from "@mui/icons-material"; +import { Box, Button, Card, Stack, Typography } from "@mui/material"; +import React from "react"; +import ErrorIcon from "./ErrorIcon"; + +export default function Error(props: ErrorProps) { + const { onBack } = props; + return ( + + + + + + + + + Analyze and improve models collaboratively with your team + + + The Model Evaluation panel currently supports only classification, + detection, and segmentation evaluations + + + + + ); +} + +type ErrorProps = { + onBack: () => void; +}; diff --git a/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/ErrorIcon.tsx b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/ErrorIcon.tsx new file mode 100644 index 0000000000..8f595c416d --- /dev/null +++ b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/ErrorIcon.tsx @@ -0,0 +1,33 @@ +import { SvgIcon, SvgIconProps } from "@mui/material"; +import React from "react"; + +export default function ErrorIcon(props: SvgIconProps) { + return ( + + + + + + + + + ); +} diff --git a/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx index 69aa0a4b39..819fdbd5b5 100644 --- a/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx +++ b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx @@ -42,6 +42,7 @@ import { } from "@mui/material"; import React, { useEffect, useMemo, useState } from "react"; import { useRecoilState, useSetRecoilState } from "recoil"; +import Error from "./Error"; import EvaluationNotes from "./EvaluationNotes"; import EvaluationPlot from "./EvaluationPlot"; import Status from "./Status"; @@ -90,6 +91,14 @@ export default function Evaluation(props: EvaluationProps) { const evaluation = data?.[`evaluation_${compareKey}`]; return evaluation; }, [data]); + const evaluationError = useMemo(() => { + const evaluation = data?.[`evaluation_${name}_error`]; + return evaluation; + }, [data]); + const compareEvaluationError = useMemo(() => { + const evaluation = data?.[`evaluation_${compareKey}_error`]; + return evaluation; + }, [data]); const confusionMatrix = useMemo(() => { return getMatrix(evaluation?.confusion_matrices, confusionMatrixConfig); }, [evaluation, confusionMatrixConfig]); @@ -145,6 +154,10 @@ export default function Evaluation(props: EvaluationProps) { setConfusionMatrixDialogConfig((state) => ({ ...state, open: false })); }; + if (evaluationError) { + return ; + } + if (!evaluation) { return ( - Compare against + + Compare against + {compareEvaluationError && ( + + Unsupported model evaluation type + + )} + {compareKeys.length === 0 ? ( Date: Fri, 6 Dec 2024 10:40:12 -0500 Subject: [PATCH 061/104] use mask targets in model evaluation panel --- .../NativeModelEvaluationView/Evaluation.tsx | 50 ++++++++++++++++--- .../panels/model_evaluation/__init__.py | 19 ++++++- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx index 819fdbd5b5..cb22817d63 100644 --- a/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx +++ b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx @@ -99,15 +99,32 @@ export default function Evaluation(props: EvaluationProps) { const evaluation = data?.[`evaluation_${compareKey}_error`]; return evaluation; }, [data]); + const evaluationMaskTargets = useMemo(() => { + return evaluation?.mask_targets || {}; + }, [evaluation]); + const compareEvaluationMaskTargets = useMemo(() => { + return compareEvaluation?.mask_targets || {}; + }, [compareEvaluation]); const confusionMatrix = useMemo(() => { - return getMatrix(evaluation?.confusion_matrices, confusionMatrixConfig); - }, [evaluation, confusionMatrixConfig]); + return getMatrix( + evaluation?.confusion_matrices, + confusionMatrixConfig, + evaluationMaskTargets + ); + }, [evaluation, confusionMatrixConfig, evaluationMaskTargets]); const compareConfusionMatrix = useMemo(() => { return getMatrix( compareEvaluation?.confusion_matrices, - confusionMatrixConfig + confusionMatrixConfig, + evaluationMaskTargets, + compareEvaluationMaskTargets ); - }, [compareEvaluation, confusionMatrixConfig]); + }, [ + compareEvaluation, + confusionMatrixConfig, + evaluationMaskTargets, + compareEvaluationMaskTargets, + ]); const compareKeys = useMemo(() => { const keys: string[] = []; const evaluations = data?.evaluations || []; @@ -452,9 +469,12 @@ export default function Evaluation(props: EvaluationProps) { if (!perClassPerformance[metric]) { perClassPerformance[metric] = []; } + const maskTarget = evaluationMaskTargets?.[key]; + const compareMaskTarget = compareEvaluationMaskTargets?.[key]; perClassPerformance[metric].push({ id: key, - property: key, + property: maskTarget || key, + compareProperty: compareMaskTarget || maskTarget || key, value: metrics[metric], compareValue: compareMetrics[metric], }); @@ -1059,7 +1079,10 @@ export default function Evaluation(props: EvaluationProps) { y: classPerformance.map( (metrics) => metrics.compareValue ), - x: classPerformance.map((metrics) => metrics.property), + x: classPerformance.map( + (metrics) => + metrics.compareProperty || metrics.property + ), type: "histogram", name: `${CLASS_LABELS[performanceClass]} per class`, marker: { @@ -1218,6 +1241,10 @@ export default function Evaluation(props: EvaluationProps) { layout={{ yaxis: { autorange: "reversed", + type: "category", + }, + xaxis: { + type: "category", }, }} /> @@ -1258,6 +1285,10 @@ export default function Evaluation(props: EvaluationProps) { layout={{ yaxis: { autorange: "reversed", + type: "category", + }, + xaxis: { + type: "category", }, }} /> @@ -1613,14 +1644,17 @@ function formatPerClassPerformance(perClassPerformance, barConfig) { return computedPerClassPerformance; } -function getMatrix(matrices, config) { +function getMatrix(matrices, config, maskTargets, compareMaskTargets?) { if (!matrices) return; const { sortBy = "az", limit } = config; const parsedLimit = typeof limit === "number" ? limit : undefined; const classes = matrices[`${sortBy}_classes`].slice(0, parsedLimit); const matrix = matrices[`${sortBy}_matrix`].slice(0, parsedLimit); const colorscale = matrices[`${sortBy}_colorscale`]; - return { labels: classes, matrix, colorscale }; + const labels = classes.map((c) => { + return compareMaskTargets?.[c] || maskTargets?.[c] || c; + }); + return { labels, matrix, colorscale }; } function getConfigLabel({ config, type, dashed }) { diff --git a/fiftyone/operators/builtins/panels/model_evaluation/__init__.py b/fiftyone/operators/builtins/panels/model_evaluation/__init__.py index ef8d9b1c47..7b27aa45a8 100644 --- a/fiftyone/operators/builtins/panels/model_evaluation/__init__.py +++ b/fiftyone/operators/builtins/panels/model_evaluation/__init__.py @@ -288,6 +288,16 @@ def get_confusion_matrices(self, results): "lc_colorscale": lc_colorscale, } + def get_mask_targets(self, dataset, gt_field): + mask_targets = dataset.mask_targets.get(gt_field, None) + if mask_targets: + return mask_targets + + if dataset.default_mask_targets: + return dataset.default_mask_targets + + return None + def load_evaluation(self, ctx): view_state = ctx.panel.get_state("view") or {} eval_key = view_state.get("key") @@ -300,7 +310,6 @@ def load_evaluation(self, ctx): ) if evaluation_data is None: info = ctx.dataset.get_evaluation_info(computed_eval_key) - serialized_info = info.serialize() evaluation_type = info.config.type if evaluation_type not in SUPPORTED_EVALUATION_TYPES: ctx.panel.set_data( @@ -308,6 +317,13 @@ def load_evaluation(self, ctx): {"error": "unsupported", "info": serialized_info}, ) return + serialized_info = info.serialize() + gt_field = info.config.gt_field + mask_targets = ( + self.get_mask_targets(ctx.dataset, gt_field) + if evaluation_type == "segmentation" + else None + ) results = ctx.dataset.load_evaluation_results(computed_eval_key) metrics = results.metrics() per_class_metrics = self.get_per_class_metrics(info, results) @@ -323,6 +339,7 @@ def load_evaluation(self, ctx): "info": serialized_info, "confusion_matrices": self.get_confusion_matrices(results), "per_class_metrics": per_class_metrics, + "mask_targets": mask_targets, } if ENABLE_CACHING: # Cache the evaluation data From 1ddd2bfe601eb8d83f0c8b29360682884b95ce4b Mon Sep 17 00:00:00 2001 From: Benjamin Kane Date: Tue, 17 Dec 2024 14:57:19 -0500 Subject: [PATCH 062/104] do not add valid list field filters to collapsed paths (#5280) --- app/packages/state/src/recoil/sidebar.test.ts | 24 +++++++++++++++++-- app/packages/state/src/recoil/sidebar.ts | 12 +++++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/app/packages/state/src/recoil/sidebar.test.ts b/app/packages/state/src/recoil/sidebar.test.ts index a69542c689..2aa826fc9b 100644 --- a/app/packages/state/src/recoil/sidebar.test.ts +++ b/app/packages/state/src/recoil/sidebar.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("recoil"); vi.mock("recoil-relay"); -import { Field } from "@fiftyone/utilities"; -import { setMockAtoms, TestSelector } from "../../../../__mocks__/recoil"; +import { DICT_FIELD, Field, STRING_FIELD } from "@fiftyone/utilities"; +import { TestSelector, setMockAtoms } from "../../../../__mocks__/recoil"; import * as sidebar from "./sidebar"; const mockFields = { @@ -566,3 +566,23 @@ describe("hiddenNoneGroups selector", () => { expect(testHiddenNoneGroups()).toStrictEqual(present); }); }); + +describe("collapsedPaths resolution", () => { + it("does not add valid list fields (i.e. with a primitive subfield)", () => { + setMockAtoms({ + field: (path) => + path === "dict_list" + ? { subfield: DICT_FIELD } + : { subfield: STRING_FIELD }, + fieldPaths: ({ ftype }) => + ftype === DICT_FIELD ? [] : ["dict_list", "string_list"], + fields: () => [], + }); + + const collapsed = >( + (sidebar.collapsedPaths) + ); + + expect(collapsed()).toStrictEqual(new Set(["dict_list"])); + }); +}); diff --git a/app/packages/state/src/recoil/sidebar.ts b/app/packages/state/src/recoil/sidebar.ts index d68c42865e..d92783dbdd 100644 --- a/app/packages/state/src/recoil/sidebar.ts +++ b/app/packages/state/src/recoil/sidebar.ts @@ -20,6 +20,7 @@ import { LABELS_PATH, LABEL_DOC_TYPES, LIST_FIELD, + UNSUPPORTED_FILTER_TYPES, VALID_LABEL_TYPES, VALID_PRIMITIVE_TYPES, withPath, @@ -292,7 +293,6 @@ export const resolveGroups = ( "other", fieldsMatcher(frameFields, () => true, present, "frames.") ); - return groups; }; @@ -761,11 +761,16 @@ export const isDisabledFilterPath = selectorFamily({ get(disabledFilterPaths).has(path), }); -const collapsedPaths = selector>({ +export const collapsedPaths = selector>({ key: "collapsedPaths", get: ({ get }) => { let paths = [...get(fieldPaths({ ftype: DICT_FIELD }))]; - paths = [...paths, ...get(fieldPaths({ ftype: LIST_FIELD }))]; + paths = [ + ...paths, + ...get(fieldPaths({ ftype: LIST_FIELD })).filter((path) => + UNSUPPORTED_FILTER_TYPES.includes(get(field(path)).subfield) + ), + ]; for (const { fields: fieldsData, name: prefix } of get( fields({ ftype: EMBEDDED_DOCUMENT_FIELD, space: State.SPACE.SAMPLE }) @@ -877,6 +882,7 @@ export const groupShown = selectorFamily< if (["tags"].includes(group)) { return null; } + return ( !data.paths.length || !data.paths.every((path) => get(collapsedPaths).has(path)) From f7040793c640af3bc52decfd1447518d10173fe4 Mon Sep 17 00:00:00 2001 From: Benjamin Kane Date: Tue, 17 Dec 2024 14:59:09 -0500 Subject: [PATCH 063/104] add extended selection (#5286) --- app/packages/state/src/recoil/filters.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/packages/state/src/recoil/filters.ts b/app/packages/state/src/recoil/filters.ts index a22036bfda..d8d91d67ff 100644 --- a/app/packages/state/src/recoil/filters.ts +++ b/app/packages/state/src/recoil/filters.ts @@ -6,6 +6,7 @@ import { import { VALID_PRIMITIVE_TYPES } from "@fiftyone/utilities"; import { DefaultValue, selectorFamily } from "recoil"; import { getSessionRef, sessionAtom } from "../session"; +import { extendedSelection } from "./atoms"; import { pathHasIndexes, queryPerformance } from "./queryPerformance"; import { expandPath, fields } from "./schema"; import { hiddenLabelIds, isFrameField } from "./selectors"; @@ -107,8 +108,10 @@ export const hasFilters = selectorFamily({ ({ get }) => { const f = Object.keys(get(modal ? modalFilters : filters)).length > 0; const hidden = Boolean(modal && get(hiddenLabelIds).size); + const selection = + !modal && Boolean(get(extendedSelection)?.selection?.length); - return f || hidden; + return f || hidden || selection; }, }); From 9c3cb74832267105f280cab1131a8fce9516fd65 Mon Sep 17 00:00:00 2001 From: Sashank Aryal Date: Tue, 17 Dec 2024 14:38:45 -0600 Subject: [PATCH 064/104] add detections fields to additional media fields --- fiftyone/server/metadata.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fiftyone/server/metadata.py b/fiftyone/server/metadata.py index 6992447e4a..b5303731b9 100644 --- a/fiftyone/server/metadata.py +++ b/fiftyone/server/metadata.py @@ -31,6 +31,8 @@ logger = logging.getLogger(__name__) _ADDITIONAL_MEDIA_FIELDS = { + fol.Detection: "mask_path", + fol.Detections: "mask_path", fol.Heatmap: "map_path", fol.Segmentation: "mask_path", OrthographicProjectionMetadata: "filepath", From bd7d2de1c942c640ddaf961892aeac8d81ecc8cc Mon Sep 17 00:00:00 2001 From: Sashank Aryal Date: Tue, 17 Dec 2024 14:40:36 -0600 Subject: [PATCH 065/104] use pydash.get instead of _deep_get --- fiftyone/server/metadata.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/fiftyone/server/metadata.py b/fiftyone/server/metadata.py index b5303731b9..bfab94e403 100644 --- a/fiftyone/server/metadata.py +++ b/fiftyone/server/metadata.py @@ -12,6 +12,7 @@ import typing as t from functools import reduce +from pydash import get import asyncio import aiofiles @@ -415,7 +416,7 @@ def _create_media_urls( media_urls = [] for field in media_fields: - path = _deep_get(sample, field) + path = get(sample, field) if path not in cache: cache[path] = path @@ -452,15 +453,3 @@ def _get_additional_media_fields( additional.append(f"{field_name}.{subfield_name}") return opm_field, additional - - -def _deep_get(sample, keys, default=None): - """ - Get a value from a nested dictionary by specifying keys delimited by '.', - similar to lodash's ``_.get()``. - """ - return reduce( - lambda d, key: d.get(key, default) if isinstance(d, dict) else default, - keys.split("."), - sample, - ) From d0462b9f7026dcc8574ca514ba0fbbc731e30722 Mon Sep 17 00:00:00 2001 From: Sashank Aryal Date: Tue, 17 Dec 2024 14:44:45 -0600 Subject: [PATCH 066/104] use detections fields in media urls creation, too --- fiftyone/server/metadata.py | 38 ++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/fiftyone/server/metadata.py b/fiftyone/server/metadata.py index bfab94e403..47d3a41bf8 100644 --- a/fiftyone/server/metadata.py +++ b/fiftyone/server/metadata.py @@ -71,7 +71,11 @@ async def get_metadata( filepath = sample["filepath"] metadata = sample.get("metadata", None) - opm_field, additional_fields = _get_additional_media_fields(collection) + ( + opm_field, + detections_fields, + additional_fields, + ) = _get_additional_media_fields(collection) filepath_result, filepath_source, urls = _create_media_urls( collection, @@ -80,6 +84,7 @@ async def get_metadata( url_cache, additional_fields=additional_fields, opm_field=opm_field, + detections_fields=detections_fields, ) if filepath_result is not None: filepath = filepath_result @@ -392,6 +397,7 @@ def _create_media_urls( cache: t.Dict, additional_fields: t.Optional[t.List[str]] = None, opm_field: t.Optional[str] = None, + detections_fields: t.Optional[t.List[str]] = None, ) -> t.Dict[str, str]: filepath_source = None media_fields = collection.app_config.media_fields.copy() @@ -399,6 +405,23 @@ def _create_media_urls( if additional_fields is not None: media_fields.extend(additional_fields) + if detections_fields is not None: + for field in detections_fields: + detections = get(sample, field) + + if not detections: + continue + + detections_list = get(detections, "detections") + + if not detections_list or len(detections_list) == 0: + continue + + len_detections = len(detections_list) + + for i in range(len_detections): + media_fields.append(f"{field}.detections[{i}].mask_path") + if ( sample_media_type == fom.POINT_CLOUD or sample_media_type == fom.THREE_D @@ -438,6 +461,8 @@ def _get_additional_media_fields( ) -> t.List[str]: additional = [] opm_field = None + detections_fields = None + for cls, subfield_name in _ADDITIONAL_MEDIA_FIELDS.items(): for field_name, field in collection.get_field_schema( flat=True @@ -450,6 +475,13 @@ def _get_additional_media_fields( if cls == OrthographicProjectionMetadata: opm_field = field_name - additional.append(f"{field_name}.{subfield_name}") + if cls == fol.Detections: + if detections_fields is None: + detections_fields = [field_name] + else: + detections_fields.append(field_name) + + else: + additional.append(f"{field_name}.{subfield_name}") - return opm_field, additional + return opm_field, detections_fields, additional From 89d18538603cd08bd62bf6a8ab7eb45e89a1e7ab Mon Sep 17 00:00:00 2001 From: Sashank Aryal Date: Tue, 17 Dec 2024 14:45:28 -0600 Subject: [PATCH 067/104] add support for collection overlay types in disk decoder --- .../looker/src/worker/disk-overlay-decoder.ts | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/app/packages/looker/src/worker/disk-overlay-decoder.ts b/app/packages/looker/src/worker/disk-overlay-decoder.ts index c5ac65acd1..a7a793b4a9 100644 --- a/app/packages/looker/src/worker/disk-overlay-decoder.ts +++ b/app/packages/looker/src/worker/disk-overlay-decoder.ts @@ -25,12 +25,17 @@ export const decodeOverlayOnDisk = async ( sources: { [path: string]: string }, cls: string, maskPathDecodingPromises: Promise[] = [], - maskTargetsBuffers: ArrayBuffer[] = [] + maskTargetsBuffers: ArrayBuffer[] = [], + overlayCollectionProcessingParams: + | { idx: number; cls: string } + | undefined = undefined ) => { // handle all list types here - if (cls === DETECTIONS) { + if (cls === DETECTIONS && label.detections) { const promises: Promise[] = []; - for (const detection of label.detections) { + + for (let i = 0; i < label.detections.length; i++) { + const detection = label.detections[i]; promises.push( decodeOverlayOnDisk( field, @@ -38,10 +43,11 @@ export const decodeOverlayOnDisk = async ( coloring, customizeColorSetting, colorscale, - {}, + sources, DETECTION, maskPathDecodingPromises, - maskTargetsBuffers + maskTargetsBuffers, + { idx: i, cls: DETECTIONS } ) ); } @@ -74,6 +80,17 @@ export const decodeOverlayOnDisk = async ( return; } + // if we have an explicit source defined from sample.urls, use that + // otherwise, use the path field from the label + let source = sources[`${field}.${overlayPathField}`]; + + if (typeof overlayCollectionProcessingParams !== "undefined") { + source = + sources[ + `${field}.${overlayCollectionProcessingParams.cls}[${overlayCollectionProcessingParams.idx}].${overlayPathField}` + ]; + } + // convert absolute file path to a URL that we can "fetch" from const overlayImageUrl = getSampleSrc( sources[`${field}.${overlayPathField}`] || label[overlayPathField] From d2038b0f549c0593b7dafd07d5fa293b1121cf96 Mon Sep 17 00:00:00 2001 From: Sashank Aryal Date: Tue, 17 Dec 2024 14:55:09 -0600 Subject: [PATCH 068/104] return if no path --- fiftyone/server/metadata.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/fiftyone/server/metadata.py b/fiftyone/server/metadata.py index 47d3a41bf8..060b95a241 100644 --- a/fiftyone/server/metadata.py +++ b/fiftyone/server/metadata.py @@ -441,6 +441,9 @@ def _create_media_urls( for field in media_fields: path = get(sample, field) + if not path: + continue + if path not in cache: cache[path] = path From cd8eaee5f2d8bfc480d6fa91380b61a81d6d2fff Mon Sep 17 00:00:00 2001 From: Sashank Aryal Date: Tue, 17 Dec 2024 15:00:35 -0600 Subject: [PATCH 069/104] fix src bug --- app/packages/looker/src/worker/disk-overlay-decoder.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/packages/looker/src/worker/disk-overlay-decoder.ts b/app/packages/looker/src/worker/disk-overlay-decoder.ts index a7a793b4a9..c3aaea0074 100644 --- a/app/packages/looker/src/worker/disk-overlay-decoder.ts +++ b/app/packages/looker/src/worker/disk-overlay-decoder.ts @@ -92,9 +92,7 @@ export const decodeOverlayOnDisk = async ( } // convert absolute file path to a URL that we can "fetch" from - const overlayImageUrl = getSampleSrc( - sources[`${field}.${overlayPathField}`] || label[overlayPathField] - ); + const overlayImageUrl = getSampleSrc(source || label[overlayPathField]); const urlTokens = overlayImageUrl.split("?"); let baseUrl = overlayImageUrl; From 5d6f683968065de6ac920554be308b40cb91592b Mon Sep 17 00:00:00 2001 From: Sashank Aryal Date: Tue, 17 Dec 2024 15:31:02 -0600 Subject: [PATCH 070/104] add clarification comment for sources --- app/packages/looker/src/worker/disk-overlay-decoder.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/packages/looker/src/worker/disk-overlay-decoder.ts b/app/packages/looker/src/worker/disk-overlay-decoder.ts index c3aaea0074..73b5d02b6c 100644 --- a/app/packages/looker/src/worker/disk-overlay-decoder.ts +++ b/app/packages/looker/src/worker/disk-overlay-decoder.ts @@ -85,9 +85,13 @@ export const decodeOverlayOnDisk = async ( let source = sources[`${field}.${overlayPathField}`]; if (typeof overlayCollectionProcessingParams !== "undefined") { + // example: for detections, we need to access the source from the parent label + // like: if field is "prediction_masks", we're trying to get "predictiion_masks.detections[INDEX].mask" source = sources[ - `${field}.${overlayCollectionProcessingParams.cls}[${overlayCollectionProcessingParams.idx}].${overlayPathField}` + `${field}.${overlayCollectionProcessingParams.cls.toLocaleLowerCase()}[${ + overlayCollectionProcessingParams.idx + }].${overlayPathField}` ]; } From bc44e3ea5221c1f898ce1d3ae41accbd3c39acc9 Mon Sep 17 00:00:00 2001 From: Sashank Aryal Date: Tue, 17 Dec 2024 15:31:35 -0600 Subject: [PATCH 071/104] don't get rid of query params if source is defined --- app/packages/looker/src/worker/disk-overlay-decoder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/packages/looker/src/worker/disk-overlay-decoder.ts b/app/packages/looker/src/worker/disk-overlay-decoder.ts index 73b5d02b6c..2948831061 100644 --- a/app/packages/looker/src/worker/disk-overlay-decoder.ts +++ b/app/packages/looker/src/worker/disk-overlay-decoder.ts @@ -102,7 +102,7 @@ export const decodeOverlayOnDisk = async ( let baseUrl = overlayImageUrl; // remove query params if not local URL - if (!urlTokens.at(1)?.startsWith("filepath=")) { + if (!urlTokens.at(1)?.startsWith("filepath=") && !source) { baseUrl = overlayImageUrl.split("?")[0]; } From d9e5f386b3d03a29716282379716b6b3058d1df4 Mon Sep 17 00:00:00 2001 From: brimoor Date: Tue, 17 Dec 2024 18:47:19 -0500 Subject: [PATCH 072/104] bump brain version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1009d750c3..0b50552ffd 100644 --- a/setup.py +++ b/setup.py @@ -74,7 +74,7 @@ def get_version(): "universal-analytics-python3>=1.0.1,<2", "pydash", # internal packages - "fiftyone-brain>=0.18.0,<0.19", + "fiftyone-brain>=0.18.2,<0.19", "fiftyone-db>=0.4,<2.0", "voxel51-eta>=0.13.0,<0.14", ] From d872a3bc4b0c2c006d17ab9caa46f57b4e2f879e Mon Sep 17 00:00:00 2001 From: Eric Hofesmann Date: Fri, 13 Dec 2024 12:46:23 -0500 Subject: [PATCH 073/104] similarity race condition patch --- fiftyone/utils/torch.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/fiftyone/utils/torch.py b/fiftyone/utils/torch.py index c8390dfc5b..112a9d5fac 100644 --- a/fiftyone/utils/torch.py +++ b/fiftyone/utils/torch.py @@ -572,9 +572,9 @@ def __exit__(self, *args): if self.config.cudnn_benchmark is not None: torch.backends.cudnn.benchmark = self._benchmark_orig self._benchmark_orig = None - - self._no_grad.__exit__(*args) - self._no_grad = None + if self._no_grad is not None: + self._no_grad.__exit__(*args) + self._no_grad = None @property def media_type(self): From 4fdcc9caf00853a04cf11e6ff809d2b9007a20ab Mon Sep 17 00:00:00 2001 From: brimoor Date: Tue, 17 Dec 2024 19:32:39 -0500 Subject: [PATCH 074/104] lint --- fiftyone/utils/torch.py | 1 + 1 file changed, 1 insertion(+) diff --git a/fiftyone/utils/torch.py b/fiftyone/utils/torch.py index 112a9d5fac..88ae1f6f20 100644 --- a/fiftyone/utils/torch.py +++ b/fiftyone/utils/torch.py @@ -572,6 +572,7 @@ def __exit__(self, *args): if self.config.cudnn_benchmark is not None: torch.backends.cudnn.benchmark = self._benchmark_orig self._benchmark_orig = None + if self._no_grad is not None: self._no_grad.__exit__(*args) self._no_grad = None From 5603258575ef6374beb6199c22d30c7474c896d0 Mon Sep 17 00:00:00 2001 From: imanjra Date: Tue, 17 Dec 2024 22:42:19 -0500 Subject: [PATCH 075/104] model evaluation load_view bug fixes --- .../NativeModelEvaluationView/Evaluation.tsx | 10 +++++++++- .../builtins/panels/model_evaluation/__init__.py | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx index c3ee377dab..cefbc3f6c6 100644 --- a/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx +++ b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx @@ -1217,6 +1217,14 @@ export default function Evaluation(props: EvaluationProps) { ].join("
") + "", }, ]} + onClick={({ points }) => { + const firstPoint = points[0]; + loadView("matrix", { + x: firstPoint.x, + y: firstPoint.y, + key: compareKey, + }); + }} layout={{ yaxis: { autorange: "reversed", @@ -1598,7 +1606,7 @@ function useActiveFilter(evaluation, compareEvaluation) { const evalKey = evaluation?.info?.key; const compareKey = compareEvaluation?.info?.key; const [stages] = useRecoilState(view); - if (stages?.length === 1) { + if (stages?.length >= 1) { const stage = stages[0]; const { _cls, kwargs } = stage; if (_cls.endsWith("FilterLabels")) { diff --git a/fiftyone/operators/builtins/panels/model_evaluation/__init__.py b/fiftyone/operators/builtins/panels/model_evaluation/__init__.py index 96684ce080..f9b1b147e9 100644 --- a/fiftyone/operators/builtins/panels/model_evaluation/__init__.py +++ b/fiftyone/operators/builtins/panels/model_evaluation/__init__.py @@ -420,7 +420,7 @@ def load_view(self, ctx): eval_key2 = view_state.get("compareKey", None) pred_field2 = None gt_field2 = None - if eval_key2 is not None: + if eval_key2: info2 = ctx.dataset.get_evaluation_info(eval_key2) pred_field2 = info2.config.pred_field if info2.config.gt_field != gt_field: From 01807ac0cf64c63a1785e9df731362d03a1aea54 Mon Sep 17 00:00:00 2001 From: Sashank Aryal Date: Wed, 18 Dec 2024 12:23:56 -0600 Subject: [PATCH 076/104] decode png header to know num channels --- .../looker/src/worker/canvas-decoder.ts | 72 ++++++++++++------- app/packages/looker/src/worker/painter.ts | 19 ++--- 2 files changed, 56 insertions(+), 35 deletions(-) diff --git a/app/packages/looker/src/worker/canvas-decoder.ts b/app/packages/looker/src/worker/canvas-decoder.ts index 390ace2a04..fdde3b91cc 100644 --- a/app/packages/looker/src/worker/canvas-decoder.ts +++ b/app/packages/looker/src/worker/canvas-decoder.ts @@ -1,45 +1,65 @@ import { OverlayMask } from "../numpy"; /** - * Checks if the given pixel data is grayscale by sampling a subset of pixels. - * The function will check at least 500 pixels or 1% of all pixels, whichever is larger. - * If the image is grayscale, the R, G, and B channels will be equal for all sampled pixels, - * and the alpha channel will always be 255. + * Reads the PNG's image header chunk to determine the color type. + * Returns the color type if PNG, otherwise undefined. */ -export const isGrayscale = (data: Uint8ClampedArray): boolean => { - const totalPixels = data.length / 4; - const checks = Math.max(500, Math.floor(totalPixels * 0.01)); - const step = Math.max(1, Math.floor(totalPixels / checks)); - - for (let p = 0; p < totalPixels; p += step) { - const i = p * 4; - const [r, g, b, a] = [data[i], data[i + 1], data[i + 2], data[i + 3]]; - if (a !== 255 || r !== g || g !== b) { - return false; +const getPngcolorType = async (blob: Blob): Promise => { + // https://www.w3.org/TR/2003/REC-PNG-20031110/#11IHDR + + // PNG signature is 8 bytes + // IHDR (image header): length(4 bytes), chunk type(4 bytes), then data(13 bytes) + // data layout of IHDR: width(4), height(4), bit depth(1), color type(1), ... + // color type is at offset: 8(signature) + 4(length) + 4(chunk type) + 8(width+height) + 1(bit depth) + // = 8 + 4 + 4 + 8 + 1 = 25 (0-based index) + + const header = new Uint8Array(await blob.slice(0, 26).arrayBuffer()); + const pngSignature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + + // check PNG signature + for (let i = 0; i < pngSignature.length; i++) { + if (header[i] !== pngSignature[i]) { + // not a PNG + return undefined; } } - return true; + + // color type at byte 25 (0-based) + const colorType = header[25]; + return colorType; }; -/** - * Decodes a given image source into an OverlayMask using an OffscreenCanvas - */ -export const decodeWithCanvas = async (blob: ImageBitmapSource) => { +export const decodeWithCanvas = async (blob: Blob) => { + let channels: number = 4; + + if (blob.type === "image/png") { + const colorType = await getPngcolorType(blob); + if (colorType !== undefined) { + // according to PNG specs: + // 0: Grayscale => 1 channel + // 2: Truecolor (RGB) => (would be 3 channels, but we can safely use 4) + // 3: Indexed-color => (palette-based, treat as non-grayscale => 4) + // 4: Grayscale+Alpha => Grayscale image (so treat as grayscale => 1) + // 6: RGBA => non-grayscale => 4 + if (colorType === 0 || colorType === 4) { + channels = 1; + } else { + channels = 4; + } + } + } + // if not PNG, use 4 channels + const imageBitmap = await createImageBitmap(blob); - const width = imageBitmap.width; - const height = imageBitmap.height; + const { width, height } = imageBitmap; const canvas = new OffscreenCanvas(width, height); - const ctx = canvas.getContext("2d"); - + const ctx = canvas.getContext("2d")!; ctx.drawImage(imageBitmap, 0, 0); imageBitmap.close(); const imageData = ctx.getImageData(0, 0, width, height); - // for nongrayscale images, channel is guaranteed to be 4 (RGBA) - const channels = isGrayscale(imageData.data) ? 1 : 4; - if (channels === 1) { // get rid of the G, B, and A channels, new buffer will be 1/4 the size const data = new Uint8ClampedArray(width * height); diff --git a/app/packages/looker/src/worker/painter.ts b/app/packages/looker/src/worker/painter.ts index 6730d90cac..1e8675aa5a 100644 --- a/app/packages/looker/src/worker/painter.ts +++ b/app/packages/looker/src/worker/painter.ts @@ -113,6 +113,7 @@ export const PainterFactory = (requestColor) => ({ } } + const numChannels = label.mask.data.channels; const overlay = new Uint32Array(label.mask.image); const targets = new ARRAY_TYPES[label.mask.data.arrayType]( label.mask.data.buffer @@ -120,16 +121,16 @@ export const PainterFactory = (requestColor) => ({ const bitColor = get32BitColor(color); if (label.mask_path) { - // putImageData results in an UInt8ClampedArray (for both grayscale or RGB masks), - // where each pixel is represented by 4 bytes (RGBA) - // it's packed like: [R, G, B, A, R, G, B, A, ...] - // use first channel info to determine if the pixel is in the mask - // skip second (G), third (B) and fourth (A) channels - for (let i = 0; i < targets.length; i += 4) { + // putImageData results in an UInt8ClampedArray, + // if image is grayscale, it'll be packed as: + // [I, I, I, I, I...], where I is the intensity value + // or else it'll be packed as: + // [R, G, B, A, R, G, B, A...] + // for non-grayscale masks, we can check every nth byte, + // where n = numChannels, to check for whether or not the pixel is part of the mask + for (let i = 0; i < targets.length; i += numChannels) { if (targets[i]) { - // overlay image is a Uint32Array, where each pixel is represented by 4 bytes (RGBA) - // so we need to divide by 4 to get the correct index to assign 32 bit color - const overlayIndex = i / 4; + const overlayIndex = i / numChannels; overlay[overlayIndex] = bitColor; } } From b86906ac09eb1370d1ea37ae40aa24beec33ddf6 Mon Sep 17 00:00:00 2001 From: Sashank Aryal Date: Wed, 18 Dec 2024 12:27:52 -0600 Subject: [PATCH 077/104] use const png signature --- app/packages/looker/src/worker/canvas-decoder.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/packages/looker/src/worker/canvas-decoder.ts b/app/packages/looker/src/worker/canvas-decoder.ts index fdde3b91cc..56749a6d2e 100644 --- a/app/packages/looker/src/worker/canvas-decoder.ts +++ b/app/packages/looker/src/worker/canvas-decoder.ts @@ -1,5 +1,6 @@ import { OverlayMask } from "../numpy"; +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; /** * Reads the PNG's image header chunk to determine the color type. * Returns the color type if PNG, otherwise undefined. @@ -14,11 +15,10 @@ const getPngcolorType = async (blob: Blob): Promise => { // = 8 + 4 + 4 + 8 + 1 = 25 (0-based index) const header = new Uint8Array(await blob.slice(0, 26).arrayBuffer()); - const pngSignature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; // check PNG signature - for (let i = 0; i < pngSignature.length; i++) { - if (header[i] !== pngSignature[i]) { + for (let i = 0; i < PNG_SIGNATURE.length; i++) { + if (header[i] !== PNG_SIGNATURE[i]) { // not a PNG return undefined; } From b4acf0fb52fc5385b653e8d5e1c109ff0841564d Mon Sep 17 00:00:00 2001 From: Sashank Aryal Date: Wed, 18 Dec 2024 13:09:35 -0600 Subject: [PATCH 078/104] remove invalid tests --- .../looker/src/worker/canvas-decoder.test.ts | 43 ------------------- 1 file changed, 43 deletions(-) delete mode 100644 app/packages/looker/src/worker/canvas-decoder.test.ts diff --git a/app/packages/looker/src/worker/canvas-decoder.test.ts b/app/packages/looker/src/worker/canvas-decoder.test.ts deleted file mode 100644 index 427b3c6131..0000000000 --- a/app/packages/looker/src/worker/canvas-decoder.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isGrayscale } from "./canvas-decoder"; - -const createData = ( - pixels: Array<[number, number, number, number]> -): Uint8ClampedArray => { - return new Uint8ClampedArray(pixels.flat()); -}; - -describe("isGrayscale", () => { - it("should return true for a perfectly grayscale image", () => { - const data = createData(Array(100).fill([100, 100, 100, 255])); - expect(isGrayscale(data)).toBe(true); - }); - - it("should return false if alpha is not 255", () => { - const data = createData([ - [100, 100, 100, 255], - [100, 100, 100, 254], - ...Array(98).fill([100, 100, 100, 255]), - ]); - expect(isGrayscale(data)).toBe(false); - }); - - it("should return false if any pixel is not grayscale", () => { - const data = createData([ - [100, 100, 100, 255], - [100, 101, 100, 255], - ...Array(98).fill([100, 100, 100, 255]), - ]); - expect(isGrayscale(data)).toBe(false); - }); - - it("should detect a non-grayscale pixel placed deep enough to ensure at least 1% of pixels are checked", () => { - // large image: 100,000 pixels. 1% of 100,000 is 1,000. - // the function will check at least 1,000 pixels. - // place a non-grayscale pixel after 800 pixels. - const pixels = Array(100000).fill([50, 50, 50, 255]); - pixels[800] = [50, 51, 50, 255]; // this is within the first 1% of pixels - const data = createData(pixels); - expect(isGrayscale(data)).toBe(false); - }); -}); From 94c3fe998a19ffb53496144eb05f2d56823b0118 Mon Sep 17 00:00:00 2001 From: Benjamin Kane Date: Wed, 18 Dec 2024 15:06:25 -0500 Subject: [PATCH 079/104] Reset to initial buffers in video looker (#5293) * reset buffers to initial state, handle single frame * assert grid tagging with locators --- app/packages/looker/src/elements/video.ts | 11 ++++++----- app/packages/looker/src/lookers/video.ts | 2 ++ e2e-pw/src/oss/specs/grid-tagging.spec.ts | 17 +++++++++++------ 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/app/packages/looker/src/elements/video.ts b/app/packages/looker/src/elements/video.ts index 0954e47e01..8f5bfcfcd1 100644 --- a/app/packages/looker/src/elements/video.ts +++ b/app/packages/looker/src/elements/video.ts @@ -65,17 +65,18 @@ export class LoaderBar extends BaseElement { }: Readonly) { const shown = !error && hovering && (waitingForVideo || buffering || waitingToStream); + + if (shown === this.shown) { + return this.element; + } + const start = lockedToSupport ? support[0] : 1; const end = lockedToSupport ? support[1] : getFrameNumber(duration, duration, frameRate); - if (shown === this.shown || start === end) { - return this.element; - } this.shown = shown; - - if (this.shown) { + if (this.shown && start !== end) { this.element.style.display = "block"; } else { this.element.style.display = "none"; diff --git a/app/packages/looker/src/lookers/video.ts b/app/packages/looker/src/lookers/video.ts index 2fe24f7fab..af4405e3ba 100644 --- a/app/packages/looker/src/lookers/video.ts +++ b/app/packages/looker/src/lookers/video.ts @@ -52,6 +52,7 @@ export class VideoLooker extends AbstractLooker { if (LOOKER_WITH_READER === this) { clearReader(); LOOKER_WITH_READER = null; + this.state.buffers = this.initialBuffers(this.state.config); } super.detach(); } @@ -361,6 +362,7 @@ export class VideoLooker extends AbstractLooker { if (LOOKER_WITH_READER === this) { if (this.state.config.thumbnail && !this.state.hovering) { clearReader(); + this.state.buffers = this.initialBuffers(this.state.config); LOOKER_WITH_READER = null; } } diff --git a/e2e-pw/src/oss/specs/grid-tagging.spec.ts b/e2e-pw/src/oss/specs/grid-tagging.spec.ts index 33abd664f8..2fd65c70fa 100644 --- a/e2e-pw/src/oss/specs/grid-tagging.spec.ts +++ b/e2e-pw/src/oss/specs/grid-tagging.spec.ts @@ -44,9 +44,10 @@ test("grid tagging", async ({ fiftyoneLoader, grid, page, sidebar }) => { await sidebar.clickFieldCheckbox("filepath"); await sidebar.clickFieldCheckbox("tags"); await grid.scrollBottom(); - await expect(await grid.locator).toHaveScreenshot("grid-untagged.png", { - animations: "allow", - }); + for (let i = 31; i <= 54; i++) { + const locator = grid.locator.getByText(`/tmp/${i}.png`); + await expect(locator).toBeVisible(); + } await grid.run(async () => { await grid.actionsRow.toggleTagSamplesOrLabels(); @@ -54,7 +55,11 @@ test("grid tagging", async ({ fiftyoneLoader, grid, page, sidebar }) => { await grid.tagger.addNewTag("sample", "grid-test"); }); - await expect(await grid.locator).toHaveScreenshot("grid-tagged.png", { - animations: "allow", - }); + for (let i = 31; i <= 54; i++) { + const locator = grid.locator.getByText(`/tmp/${i}.png`); + await expect(locator).toBeVisible(); + await expect( + locator.locator("..").getByTestId("tag-tags-grid-test") + ).toBeVisible(); + } }); From cf3d4a2ec4d62679ed8ee347167705f9e2645b8c Mon Sep 17 00:00:00 2001 From: manivoxel51 Date: Wed, 18 Dec 2024 13:05:12 -0800 Subject: [PATCH 080/104] Fixes an issue where spliting the panel space wrongly triggers on_unload --- app/packages/operators/src/CustomPanel.tsx | 3 +++ app/packages/operators/src/useCustomPanelHooks.ts | 8 -------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/app/packages/operators/src/CustomPanel.tsx b/app/packages/operators/src/CustomPanel.tsx index 14d3548ca0..3674374027 100644 --- a/app/packages/operators/src/CustomPanel.tsx +++ b/app/packages/operators/src/CustomPanel.tsx @@ -14,12 +14,14 @@ import { useActivePanelEventsCount } from "./hooks"; import { Property } from "./types"; import { CustomPanelProps, useCustomPanelHooks } from "./useCustomPanelHooks"; import { useTrackEvent } from "@fiftyone/analytics"; +import usePanelEvent from "./usePanelEvent"; export function CustomPanel(props: CustomPanelProps) { const { panelId, dimensions, panelName, panelLabel, isModalPanel } = props; const { height, width } = dimensions?.bounds || {}; const { count } = useActivePanelEventsCount(panelId); const [_, setLoading] = usePanelLoading(panelId); + const triggerPanelEvent = usePanelEvent(); const { handlePanelStateChange, @@ -36,6 +38,7 @@ export function CustomPanel(props: CustomPanelProps) { setPanelCloseEffect(() => { clearUseKeyStores(panelId); trackEvent("close_panel", { panel: panelName }); + triggerPanelEvent(panelId, { operator: props.onUnLoad }); }); }, []); diff --git a/app/packages/operators/src/useCustomPanelHooks.ts b/app/packages/operators/src/useCustomPanelHooks.ts index 7363234138..60c685255e 100644 --- a/app/packages/operators/src/useCustomPanelHooks.ts +++ b/app/packages/operators/src/useCustomPanelHooks.ts @@ -165,14 +165,6 @@ export function useCustomPanelHooks(props: CustomPanelProps): CustomPanelHooks { triggerPanelEvent, ]); - useEffect(() => { - return () => { - if (props.onUnLoad) { - triggerPanelEvent(panelId, { operator: props.onUnLoad }); - } - }; - }, []); // eslint-disable-line react-hooks/exhaustive-deps - const handlePanelStateChangeOpDebounced = useMemo(() => { return debounce( (state, onChange, panelId) => { From c82bb8106953fd42bb37e31ec716cee5dfe5f8ff Mon Sep 17 00:00:00 2001 From: Benjamin Kane Date: Wed, 18 Dec 2024 16:46:14 -0500 Subject: [PATCH 081/104] Fix cached looker font sizes (#5287) * update font size when pulling from looker cache * add fontSize to attach --- app/packages/core/src/components/Grid/Grid.tsx | 5 ++++- .../core/src/components/ImageContainerHeader.tsx | 2 +- app/packages/looker/src/lookers/abstract.ts | 10 ++++++++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/app/packages/core/src/components/Grid/Grid.tsx b/app/packages/core/src/components/Grid/Grid.tsx index 303146c09a..8948d8b1ff 100644 --- a/app/packages/core/src/components/Grid/Grid.tsx +++ b/app/packages/core/src/components/Grid/Grid.tsx @@ -72,7 +72,10 @@ function Grid() { get: (next) => page(next), render: (id, element, dimensions, zooming) => { if (lookerStore.has(id.description)) { - lookerStore.get(id.description)?.attach(element, dimensions); + lookerStore + .get(id.description) + ?.attach(element, dimensions, getFontSize()); + return; } diff --git a/app/packages/core/src/components/ImageContainerHeader.tsx b/app/packages/core/src/components/ImageContainerHeader.tsx index ed6e73b339..1c7c804523 100644 --- a/app/packages/core/src/components/ImageContainerHeader.tsx +++ b/app/packages/core/src/components/ImageContainerHeader.tsx @@ -3,7 +3,7 @@ import * as fos from "@fiftyone/state"; import { isGroup as isGroupAtom } from "@fiftyone/state"; import { Apps, ImageAspectRatio } from "@mui/icons-material"; import Color from "color"; -import { Suspense, useMemo } from "react"; +import React, { Suspense, useMemo } from "react"; import { constSelector, useRecoilCallback, diff --git a/app/packages/looker/src/lookers/abstract.ts b/app/packages/looker/src/lookers/abstract.ts index 1043c4d7b8..08991adba9 100644 --- a/app/packages/looker/src/lookers/abstract.ts +++ b/app/packages/looker/src/lookers/abstract.ts @@ -469,13 +469,18 @@ export abstract class AbstractLooker< /** * Attaches the instance to the provided HTMLElement and adds event listeners */ - attach(element: HTMLElement | string, dimensions?: Dimensions): void { + attach( + element: HTMLElement | string, + dimensions?: Dimensions, + fontSize?: number + ): void { if (typeof element === "string") { element = document.getElementById(element); } if (element === this.lookerElement.element.parentElement) { - this.state.disabled && this.updater({ disabled: false }); + this.state.disabled && + this.updater({ disabled: false, options: { fontSize } }); return; } @@ -491,6 +496,7 @@ export abstract class AbstractLooker< this.updater({ windowBBox: dimensions ? [0, 0, ...dimensions] : getElementBBox(element), disabled: false, + options: { fontSize }, }); element.appendChild(this.lookerElement.element); !dimensions && this.resizeObserver.observe(element); From 75d31dc983367a93502a435f808112f81835f991 Mon Sep 17 00:00:00 2001 From: Sashank Aryal Date: Wed, 18 Dec 2024 21:15:47 -0600 Subject: [PATCH 082/104] remove erroneous base url extraction --- app/packages/looker/src/worker/disk-overlay-decoder.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/app/packages/looker/src/worker/disk-overlay-decoder.ts b/app/packages/looker/src/worker/disk-overlay-decoder.ts index 2948831061..32177e079c 100644 --- a/app/packages/looker/src/worker/disk-overlay-decoder.ts +++ b/app/packages/looker/src/worker/disk-overlay-decoder.ts @@ -97,19 +97,11 @@ export const decodeOverlayOnDisk = async ( // convert absolute file path to a URL that we can "fetch" from const overlayImageUrl = getSampleSrc(source || label[overlayPathField]); - const urlTokens = overlayImageUrl.split("?"); - - let baseUrl = overlayImageUrl; - - // remove query params if not local URL - if (!urlTokens.at(1)?.startsWith("filepath=") && !source) { - baseUrl = overlayImageUrl.split("?")[0]; - } let overlayImageBlob: Blob; try { const overlayImageFetchResponse = await enqueueFetch({ - url: baseUrl, + url: overlayImageUrl, options: { priority: "low" }, }); overlayImageBlob = await overlayImageFetchResponse.blob(); From b92e7472670d57ff147fa26e649340020ca4f512 Mon Sep 17 00:00:00 2001 From: imanjra Date: Thu, 19 Dec 2024 12:49:31 -0500 Subject: [PATCH 083/104] fix load_evaluation bug with serialized_info --- fiftyone/operators/builtins/panels/model_evaluation/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fiftyone/operators/builtins/panels/model_evaluation/__init__.py b/fiftyone/operators/builtins/panels/model_evaluation/__init__.py index c83e8972cf..e75052ed4b 100644 --- a/fiftyone/operators/builtins/panels/model_evaluation/__init__.py +++ b/fiftyone/operators/builtins/panels/model_evaluation/__init__.py @@ -308,13 +308,13 @@ def load_evaluation(self, ctx): if evaluation_data is None: info = ctx.dataset.get_evaluation_info(computed_eval_key) evaluation_type = info.config.type + serialized_info = info.serialize() if evaluation_type not in SUPPORTED_EVALUATION_TYPES: ctx.panel.set_data( f"evaluation_{computed_eval_key}_error", {"error": "unsupported", "info": serialized_info}, ) return - serialized_info = info.serialize() gt_field = info.config.gt_field mask_targets = ( self.get_mask_targets(ctx.dataset, gt_field) From 79349afe49ffe8495bcc797e1e1f64d000ecb4d8 Mon Sep 17 00:00:00 2001 From: imanjra Date: Tue, 10 Dec 2024 12:00:21 -0500 Subject: [PATCH 084/104] model evaluation panel permission ux tweaks --- .../NativeModelEvaluationView/Evaluate.tsx | 20 ++++++++--- .../NativeModelEvaluationView/Evaluation.tsx | 34 +++++++++++-------- .../NativeModelEvaluationView/Overview.tsx | 2 +- .../NativeModelEvaluationView/Status.tsx | 13 +++++-- 4 files changed, 47 insertions(+), 22 deletions(-) diff --git a/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluate.tsx b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluate.tsx index c9aed0c979..69022c3b5d 100644 --- a/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluate.tsx +++ b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluate.tsx @@ -1,13 +1,25 @@ import { MuiButton } from "@fiftyone/components"; import { Add } from "@mui/icons-material"; +import { Box } from "@mui/material"; import React from "react"; export default function Evaluate(props: EvaluateProps) { - const { onEvaluate } = props; + const { onEvaluate, permissions } = props; + const canEvaluate = permissions.can_evaluate; return ( - } variant="contained"> - Evaluate Model - + + } + variant="contained" + disabled={!canEvaluate} + > + Evaluate Model + + ); } diff --git a/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx index eb5b401efc..37340cc928 100644 --- a/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx +++ b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Evaluation.tsx @@ -611,20 +611,26 @@ export default function Evaluation(props: EvaluationProps) { Evaluation notes - {can_edit_note && ( - - { - setEditNoteState((note) => ({ ...note, open: true })); - }} - > - - - - )} + + { + setEditNoteState((note) => ({ ...note, open: true })); + }} + disabled={!can_edit_note} + > + + + diff --git a/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Overview.tsx b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Overview.tsx index b0aa8f99d8..643ec14b7d 100644 --- a/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Overview.tsx +++ b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Overview.tsx @@ -97,7 +97,7 @@ function EvaluationCard(props: EvaluationCardProps) { } /> )} - {status && } + {status && }
{note && } diff --git a/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Status.tsx b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Status.tsx index 346cbb3245..bc3eb52635 100644 --- a/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Status.tsx +++ b/app/packages/core/src/plugins/SchemaIO/components/NativeModelEvaluationView/Status.tsx @@ -4,10 +4,10 @@ import React from "react"; import { useTriggerEvent } from "./utils"; export default function Status(props: StatusProps) { - const { status, canEdit, setStatusEvent } = props; + const { status, canEdit, readOnly, setStatusEvent } = props; const triggerEvent = useTriggerEvent(); - if (canEdit) { + if (!readOnly) { return (