diff --git a/app/packages/core/src/components/Filters/BooleanFieldFilter.tsx b/app/packages/core/src/components/Filters/BooleanFieldFilter.tsx index 3407a01193..12769a7491 100644 --- a/app/packages/core/src/components/Filters/BooleanFieldFilter.tsx +++ b/app/packages/core/src/components/Filters/BooleanFieldFilter.tsx @@ -1,12 +1,10 @@ -import React from "react"; - import { + boolExcludeAtom, + boolIsMatchingAtom, booleanCountResults, booleanSelectedValuesAtom, - boolIsMatchingAtom, - boolOnlyMatchAtom, - boolExcludeAtom, } from "@fiftyone/state"; +import React from "react"; import CategoricalFilter from "./categoricalFilter/CategoricalFilter"; const BooleanFieldFilter = ({ @@ -27,7 +25,6 @@ const BooleanFieldFilter = ({ selectedValuesAtom={booleanSelectedValuesAtom({ path, modal })} isMatchingAtom={boolIsMatchingAtom({ path, modal })} - onlyMatchAtom={boolOnlyMatchAtom({ path, modal })} excludeAtom={boolExcludeAtom({ path, modal })} countsAtom={booleanCountResults({ path, diff --git a/app/packages/core/src/components/Filters/LabelFieldFilter.tsx b/app/packages/core/src/components/Filters/LabelFieldFilter.tsx index ea672b231f..14cfc9b2e9 100644 --- a/app/packages/core/src/components/Filters/LabelFieldFilter.tsx +++ b/app/packages/core/src/components/Filters/LabelFieldFilter.tsx @@ -2,12 +2,11 @@ import React from "react"; import { isMatchingAtom, - onlyMatchAtom, stringExcludeAtom, stringSelectedValuesAtom, } from "@fiftyone/state"; -import CategoricalFilter from "./categoricalFilter/CategoricalFilter"; import { labelTagsCount } from "../Sidebar/Entries/EntryCounts"; +import CategoricalFilter from "./categoricalFilter/CategoricalFilter"; const LabelTagFieldFilter = ({ path, @@ -27,7 +26,6 @@ const LabelTagFieldFilter = ({ selectedValuesAtom={stringSelectedValuesAtom({ modal, path })} excludeAtom={stringExcludeAtom({ modal, path })} - onlyMatchAtom={onlyMatchAtom({ modal, path })} isMatchingAtom={isMatchingAtom({ modal, path })} countsAtom={labelTagsCount({ modal, extended: false })} path={path} diff --git a/app/packages/core/src/components/Filters/NumericFieldFilter.tsx b/app/packages/core/src/components/Filters/NumericFieldFilter.tsx index 890fd81764..e33b386204 100644 --- a/app/packages/core/src/components/Filters/NumericFieldFilter.tsx +++ b/app/packages/core/src/components/Filters/NumericFieldFilter.tsx @@ -10,14 +10,14 @@ import styled from "styled-components"; import * as fos from "@fiftyone/state"; -import RangeSlider from "../Common/RangeSlider"; -import Checkbox from "../Common/Checkbox"; -import { Button } from "../utils"; import { DATE_FIELD, DATE_TIME_FIELD, FLOAT_FIELD } from "@fiftyone/utilities"; import { formatDateTime } from "../../utils/generic"; -import withSuspense from "./withSuspense"; +import Checkbox from "../Common/Checkbox"; +import RangeSlider from "../Common/RangeSlider"; import FieldLabelAndInfo from "../FieldLabelAndInfo"; +import { Button } from "../utils"; import FilterOption from "./categoricalFilter/filterOption/FilterOption"; +import withSuspense from "./withSuspense"; const NamedRangeSliderContainer = styled.div` margin: 3px; @@ -135,11 +135,6 @@ const NumericFieldFilter = ({ modal, defaultRange, }); - const onlyMatchAtom = fos.numericOnlyMatchAtom({ - path, - modal, - defaultRange, - }); const values = useRecoilValue( fos.rangeAtom({ modal, @@ -149,7 +144,6 @@ const NumericFieldFilter = ({ }) ); const setExcluded = excludeAtom ? useSetRecoilState(excludeAtom) : null; - const setOnlyMatch = onlyMatchAtom ? useSetRecoilState(onlyMatchAtom) : null; const setIsMatching = isMatchingAtom ? useSetRecoilState(isMatchingAtom) : null; @@ -207,7 +201,6 @@ const NumericFieldFilter = ({ const initializeSettings = () => { setFilter([null, null]); setExcluded && setExcluded(false); - setOnlyMatch && setOnlyMatch(true); setIsMatching && setIsMatching(!nestedField); }; @@ -304,7 +297,6 @@ const NumericFieldFilter = ({ nestedField={nestedField} shouldNotShowExclude={false} // only boolean fields don't use exclude excludeAtom={excludeAtom} - onlyMatchAtom={onlyMatchAtom} isMatchingAtom={isMatchingAtom} valueName={field?.name ?? ""} path={path} diff --git a/app/packages/core/src/components/Filters/StringFieldFilter.tsx b/app/packages/core/src/components/Filters/StringFieldFilter.tsx index aa8f3df7c1..a2697f5598 100644 --- a/app/packages/core/src/components/Filters/StringFieldFilter.tsx +++ b/app/packages/core/src/components/Filters/StringFieldFilter.tsx @@ -1,12 +1,10 @@ -import React from "react"; import * as fos from "@fiftyone/state"; - import { isMatchingAtom, - onlyMatchAtom, stringExcludeAtom, stringSelectedValuesAtom, } from "@fiftyone/state"; +import React from "react"; import CategoricalFilter from "./categoricalFilter/CategoricalFilter"; const StringFieldFilter = ({ @@ -27,7 +25,6 @@ const StringFieldFilter = ({ selectedValuesAtom={stringSelectedValuesAtom({ modal, path })} excludeAtom={stringExcludeAtom({ modal, path })} - onlyMatchAtom={onlyMatchAtom({ modal, path })} isMatchingAtom={isMatchingAtom({ modal, path })} countsAtom={fos.stringCountResults({ modal, diff --git a/app/packages/core/src/components/Filters/categoricalFilter/CategoricalFilter.tsx b/app/packages/core/src/components/Filters/categoricalFilter/CategoricalFilter.tsx index efe73f4366..bcb5ceac19 100644 --- a/app/packages/core/src/components/Filters/categoricalFilter/CategoricalFilter.tsx +++ b/app/packages/core/src/components/Filters/categoricalFilter/CategoricalFilter.tsx @@ -175,7 +175,6 @@ interface Props { selectedValuesAtom: RecoilState; excludeAtom: RecoilState; // toggles select or exclude isMatchingAtom: RecoilState; // toggles match or filter - onlyMatchAtom: RecoilState; // toggles onlyMatch mode (omit empty samples) countsAtom: RecoilValue<{ count: number; results: [T["value"], number][]; @@ -190,7 +189,6 @@ const CategoricalFilter = ({ countsAtom, selectedValuesAtom, excludeAtom, - onlyMatchAtom, isMatchingAtom, path, modal, @@ -203,6 +201,7 @@ const CategoricalFilter = ({ : path.startsWith("_label_tags") ? "label tag" : name; + const selectedCounts = useRef(new Map()); const onSelect = useOnSelect(selectedValuesAtom, selectedCounts); const useSearch = getUseSearch({ modal, path }); @@ -213,7 +212,7 @@ const CategoricalFilter = ({ // id fields should always use filter mode const neverShowExpansion = field?.ftype?.includes("ObjectIdField"); - + if (countsLoadable.state === "hasError") throw countsLoadable.contents; if (countsLoadable.state !== "hasValue") return null; const { count, results } = countsLoadable.contents; @@ -267,7 +266,6 @@ const CategoricalFilter = ({ selectedValuesAtom={selectedValuesAtom} excludeAtom={excludeAtom} isMatchingAtom={isMatchingAtom} - onlyMatchAtom={onlyMatchAtom} modal={modal} totalCount={count} selectedCounts={selectedCounts} diff --git a/app/packages/core/src/components/Filters/categoricalFilter/Wrapper.tsx b/app/packages/core/src/components/Filters/categoricalFilter/Wrapper.tsx index 2853f8b614..381371ca3a 100644 --- a/app/packages/core/src/components/Filters/categoricalFilter/Wrapper.tsx +++ b/app/packages/core/src/components/Filters/categoricalFilter/Wrapper.tsx @@ -8,18 +8,17 @@ import { import * as fos from "@fiftyone/state"; -import FilterOption from "./filterOption/FilterOption"; import Checkbox from "../../Common/Checkbox"; import { Button } from "../../utils"; import { CHECKBOX_LIMIT, nullSort } from "../utils"; -import { isKeypointLabel, V } from "./CategoricalFilter"; +import { V, isKeypointLabel } from "./CategoricalFilter"; +import FilterOption from "./filterOption/FilterOption"; interface WrapperProps { results: [V["value"], number][]; selectedValuesAtom: RecoilState; excludeAtom: RecoilState; isMatchingAtom: RecoilState; - onlyMatchAtom: RecoilState; color: string; totalCount: number; modal: boolean; @@ -34,7 +33,6 @@ const Wrapper = ({ selectedValuesAtom, excludeAtom, isMatchingAtom, - onlyMatchAtom, modal, path, selectedCounts, @@ -44,7 +42,6 @@ const Wrapper = ({ const [selected, setSelected] = useRecoilState(selectedValuesAtom); const selectedSet = new Set(selected); const setExcluded = excludeAtom ? useSetRecoilState(excludeAtom) : null; - const setOnlyMatch = onlyMatchAtom ? useSetRecoilState(onlyMatchAtom) : null; const setIsMatching = isMatchingAtom ? useSetRecoilState(isMatchingAtom) : null; @@ -87,7 +84,6 @@ const Wrapper = ({ const initializeSettings = () => { setExcluded && setExcluded(false); - setOnlyMatch && setOnlyMatch(true); setIsMatching && setIsMatching(!nestedField); }; @@ -144,7 +140,6 @@ const Wrapper = ({ nestedField={nestedField} shouldNotShowExclude={shouldNotShowExclude} excludeAtom={excludeAtom} - onlyMatchAtom={onlyMatchAtom} isMatchingAtom={isMatchingAtom} valueName={name} color={color} diff --git a/app/packages/core/src/components/Filters/categoricalFilter/filterOption/FilterOption.tsx b/app/packages/core/src/components/Filters/categoricalFilter/filterOption/FilterOption.tsx index 8cb7239ff6..29d5afa561 100644 --- a/app/packages/core/src/components/Filters/categoricalFilter/filterOption/FilterOption.tsx +++ b/app/packages/core/src/components/Filters/categoricalFilter/filterOption/FilterOption.tsx @@ -1,27 +1,24 @@ -import React, { PropsWithChildren, useEffect } from "react"; -import styled from "styled-components"; -import { RecoilState, useRecoilState, useSetRecoilState } from "recoil"; import FilterAltIcon from "@mui/icons-material/FilterAlt"; import FilterAltOffIcon from "@mui/icons-material/FilterAltOff"; -import ImageIcon from "@mui/icons-material/Image"; import HideImageIcon from "@mui/icons-material/HideImage"; +import ImageIcon from "@mui/icons-material/Image"; import { IconButton } from "@mui/material"; -import { useSpring } from "framer-motion"; import Color from "color"; +import React, { useEffect } from "react"; +import { RecoilState, useRecoilState, useSetRecoilState } from "recoil"; +import styled from "styled-components"; -import { useOutsideClick } from "@fiftyone/state"; import { useTheme } from "@fiftyone/components/src/components/ThemeProvider"; import Tooltip from "@fiftyone/components/src/components/Tooltip"; +import { useOutsideClick } from "@fiftyone/state"; -import { PopoutDiv } from "../../../utils"; -import Item from "./FilterItem"; import { Popout } from "@fiftyone/components"; +import Item from "./FilterItem"; interface Props { nestedField: string | undefined; // nested ListFields only ("detections") shouldNotShowExclude: boolean; // for BooleanFields excludeAtom: RecoilState; - onlyMatchAtom: RecoilState; isMatchingAtom: RecoilState; valueName: string; color: string; @@ -132,7 +129,6 @@ const FilterOption: React.FC = ({ nestedField, shouldNotShowExclude, excludeAtom, - onlyMatchAtom, isMatchingAtom, }) => { const isLabelTag = path?.startsWith("_label_tags"); @@ -140,7 +136,6 @@ const FilterOption: React.FC = ({ const [open, setOpen] = React.useState(false); const [excluded, setExcluded] = useRecoilState(excludeAtom); - const setOnlyMatch = onlyMatchAtom ? useSetRecoilState(onlyMatchAtom) : null; const setIsMatching = isMatchingAtom ? useSetRecoilState(isMatchingAtom) : null; @@ -213,25 +208,21 @@ const FilterOption: React.FC = ({ const onSelectFilter = () => { setExcluded && setExcluded(false); setIsMatching && setIsMatching(false); - setOnlyMatch && setOnlyMatch(true); }; const onSelectNegativeFilter = () => { setExcluded && setExcluded(true); setIsMatching && setIsMatching(false); - setOnlyMatch && setOnlyMatch(false); }; const onSelectMatch = () => { setExcluded && setExcluded(false); setIsMatching && setIsMatching(true); - setOnlyMatch && setOnlyMatch(true); }; const onSelectNegativeMatch = () => { setExcluded && setExcluded(true); setIsMatching && setIsMatching(true); - setOnlyMatch && setOnlyMatch(true); }; const children = ( diff --git a/app/packages/state/src/recoil/aggregations.ts b/app/packages/state/src/recoil/aggregations.ts index 0e2424e87d..b2e9f40fb2 100644 --- a/app/packages/state/src/recoil/aggregations.ts +++ b/app/packages/state/src/recoil/aggregations.ts @@ -203,7 +203,7 @@ export const stringCountResults = selectorFamily({ const isSkeletonPoints = VALID_KEYPOINTS.includes( get(schemaAtoms.field(parent)).embeddedDocType - ) && keys[2] === "points"; + ) && keys.slice(-1)[0] === "points"; if (isSkeletonPoints) { const skeleton = get(selectors.skeleton(parent)); diff --git a/app/packages/state/src/recoil/groups.ts b/app/packages/state/src/recoil/groups.ts index 7b6a373a09..b4cac99bad 100644 --- a/app/packages/state/src/recoil/groups.ts +++ b/app/packages/state/src/recoil/groups.ts @@ -71,7 +71,7 @@ export const groupSlices = selector({ export const hasGroupSlices = selector({ key: "hasGroupSlices", - get: ({ get }) => Boolean(get(groupSlices).length), + get: ({ get }) => get(isGroup) && Boolean(get(groupSlices).length), }); export const defaultPcdSlice = selector({ diff --git a/docs/source/faq/index.rst b/docs/source/faq/index.rst index 5933381fe6..6fcce8b59d 100644 --- a/docs/source/faq/index.rst +++ b/docs/source/faq/index.rst @@ -18,7 +18,7 @@ web browser whenever you call You can also run FiftyOne :ref:`as a desktop application ` if you prefer. -Check out the :ref:`enviornments guide ` to see how to use +Check out the :ref:`environments guide ` to see how to use FiftyOne in all common local, remote, cloud, and notebook environments. .. _faq-supported-browsers: @@ -48,7 +48,7 @@ your browser or as a desktop App. You can also set the ``desktop_app`` flag of your :ref:`FiftyOne config ` to use the desktop App by default. -Check out the :ref:`enviornments guide ` to see how to use +Check out the :ref:`environments guide ` to see how to use FiftyOne in all common local, remote, cloud, and notebook environments. .. _faq-app-no-session: @@ -518,7 +518,7 @@ On your local machine, you can :ref:`connect to these remote sessions ` using a different local port `XXXX` and `YYYY` for each. -If you do not have Fiftyone installed on your local machine, open a new +If you do not have FiftyOne installed on your local machine, open a new terminal window on your local machine and execute the following command to setup port forwarding to connect to your remote sessions: @@ -680,7 +680,7 @@ Are the Brain methods open source? Although the `core library `_ is open source and the :ref:`Brain methods ` are freely available for -use for any commerical or non-commerical purposes, the Brain methods are closed +use for any commercial or non-commercial purposes, the Brain methods are closed source. Check out the :ref:`Brain documentation ` for detailed diff --git a/docs/source/index.rst b/docs/source/index.rst index 8dee03a3c4..1aa2c26a43 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -87,10 +87,25 @@ learn how: :image_title: Detectron2 .. customimagelink:: - :image_link: tutorials/qdrant.html + :image_link: integrations/qdrant.html :image_src: https://voxel51.com/images/integrations/qdrant-128.png :image_title: Qdrant +.. customimagelink:: + :image_link: integrations/pinecone.html + :image_src: https://voxel51.com/images/integrations/pinecone-128.png + :image_title: Pinecone + +.. customimagelink:: + :image_link: tutorials/milvus.html + :image_src: https://voxel51.com/images/integrations/milvus-128.png + :image_title: Milvus + +.. customimagelink:: + :image_link: integrations/lancedb.html + :image_src: https://voxel51.com/images/integrations/lancedb-128.png + :image_title: LanceDB + .. customimagelink:: :image_link: integrations/activitynet.html :image_src: https://voxel51.com/images/integrations/activitynet-128.png @@ -137,17 +152,17 @@ learn how: :image_title: Scale AI .. customimagelink:: - :image_link: environments/index.html#google-cloud + :image_link: teams/installation.html#google-cloud-storage :image_src: https://voxel51.com/images/integrations/google-cloud-128.png :image_title: Google Cloud .. customimagelink:: - :image_link: environments/index.html#aws + :image_link: teams/installation.html#amazon-s3 :image_src: https://voxel51.com/images/integrations/aws-128.png :image_title: Amazon Web Services .. customimagelink:: - :image_link: environments/index.html#azure + :image_link: teams/installation.html#microsoft-azure :image_src: https://voxel51.com/images/integrations/azure-128.png :image_title: Azure diff --git a/docs/source/release-notes.rst b/docs/source/release-notes.rst index 3841706034..7c78888c0c 100644 --- a/docs/source/release-notes.rst +++ b/docs/source/release-notes.rst @@ -3,6 +3,63 @@ FiftyOne Release Notes .. default-role:: code +.. _release-notes-teams-v1.3.3: + +FiftyOne Teams 1.3.3 +-------------------- +*Released July 12, 2023* + +Includes all updates from :ref:`FiftyOne 0.21.3 `, plus: + +SDK + +- Added a `cache=True` option to the + :ref:`upload_media() ` utility that allows for + automatically adding any uploaded files to your local cache + +API connections + +- Fixed a bug when launching the App locally via API connections + +.. _release-notes-v0.21.3: + +FiftyOne 0.21.3 +--------------- +*Released July 12, 2023* + +News + +- Released a :ref:`Milvus integration ` for native text and + image searches on FiftyOne datasets! +- Released a :ref:`LanceDB integration ` for native text + and image searches on FiftyOne datasets! + +App + +- Added support for embedded keypoint fields in + :meth:`filter_keypoints() ` + `#3279 `_ +- Fixed keypoint filtering + `#3270 `_ +- Fixed a bug that caused non-matching samples to remain in the grid when + applying multiple sidebar filters + `#3270 `_ +- Fixed a bug when filtering by IDs in the sidebar + `#3270 `_ +- Fixed label tags grid bubbles for filterless views + `#3257 `_ + +Core + +- Added a :meth:`merge_sample() ` + method for merging individual samples into existing datasets + `#3274 `_ +- Fixed a bug when passing dict-valued `points` to + :func:`compute_visualization() ` + `#3268 `_ +- Fixed a bug when filtering keypoints stored in embedded documents + `#3279 `_ + .. _release-notes-teams-v1.3.2: FiftyOne Teams 1.3.2 @@ -24,7 +81,6 @@ App - Fixes redundant sidebar groups for custom schemas `#3250 `_ - .. _release-notes-teams-v1.3.1: FiftyOne Teams 1.3.1 @@ -49,6 +105,7 @@ API connections :ref:`direct database connections ` - Fixed a bug when connecting to Teams deployments with non-standard database names via API connections +- Fixed a bug when saving run results using API connections - Fixed a bug when deleting datasets using API connections Management SDK diff --git a/docs/source/teams/api_connection.rst b/docs/source/teams/api_connection.rst index 9c80fd154b..51bca5e2b8 100644 --- a/docs/source/teams/api_connection.rst +++ b/docs/source/teams/api_connection.rst @@ -15,8 +15,8 @@ dataset permissions *are enforced*. .. note:: - API connections are currently in beta. If preferred, you can also directly - connect to your Teams deployment's + **API connections are currently in beta.** The recommended stable solution + is to use your Teams deployment's :ref:`MongoDB connection `. Configuring an API connection diff --git a/docs/source/teams/cloud_media.rst b/docs/source/teams/cloud_media.rst index 1261fbeb23..920dfb577c 100644 --- a/docs/source/teams/cloud_media.rst +++ b/docs/source/teams/cloud_media.rst @@ -561,6 +561,7 @@ _____________ rel_dir=None, media_field="filepath", update_filepaths=False, + cache=False, overwrite=False, skip_failures=False, progress=False, @@ -582,6 +583,12 @@ _____________ media_field ("filepath"): the field containing the media paths update_filepaths (False): whether to update the ``media_field`` of each sample in the collection to its remote path + cache (False): whether to store the uploaded media in your local media + cache. The supported values are: + + - ``False`` (default): do not cache the media + - ``True`` or ``"copy"``: copy the media into your local cache + - ``"move"``: move the media into your local cache overwrite (False): whether to overwrite (True) or skip (False) existing remote files skip_failures (False): whether to gracefully continue without raising diff --git a/docs/source/teams/index.rst b/docs/source/teams/index.rst index 1f928a01ad..36bcf60b50 100644 --- a/docs/source/teams/index.rst +++ b/docs/source/teams/index.rst @@ -95,7 +95,7 @@ pages on this site apply to Teams deployments as well. Overview Installation - API Connection + API connection Cloud-backed media Roles and permissions FiftyOne Teams App diff --git a/docs/source/teams/installation.rst b/docs/source/teams/installation.rst index f6752b4a60..cbf97cd264 100644 --- a/docs/source/teams/installation.rst +++ b/docs/source/teams/installation.rst @@ -85,7 +85,7 @@ _________ To work with FiftyOne datasets whose media are stored in Amazon S3, you simply need to provide `AWS credentials `_ -to your Teams client with read access to the relevant files. +to your Teams client with read access to the relevant objects and buckets. You can do this in any of the following ways: @@ -106,6 +106,15 @@ following keys to your :ref:`media cache config `: In the above, the `.ini` file should use the syntax of the `boto3 configuration file `_. +.. note:: + + FiftyOne Teams requires either the `s3:ListBucket` or + `s3:GetBucketLocation` permission in order to access objects in S3 buckets. + + If you wish to use multi-account credentials, your credentials must have + the `s3:ListBucket` permission, as `s3:GetBucketLocation` does not support + this. + .. _teams-google-cloud: Google Cloud Storage @@ -114,7 +123,7 @@ ____________________ To work with FiftyOne datasets whose media are stored in Google Cloud Storage, you simply need to provide `service account credentials `_ -to your Teams client with read access to the relevant files. +to your Teams client with read access to the relevant objects and buckets. You can register GCP credentials on a particular machine by adding the following key to your :ref:`media cache config `: @@ -133,7 +142,7 @@ _______________ To work with FiftyOne datasets whose media are stored in Azure Storage, you simply need to provide `Azure credentials `_ -to your Teams client with read access to the relevant files. +to your Teams client with read access to the relevant objects and containers. You can do this in any of the following ways: @@ -243,7 +252,7 @@ _____ To work with FiftyOne datasets whose media are stored in `MinIO `_, you simply need to provide the credentials to your -Teams client with read access to the relevant files. +Teams client with read access to the relevant objects and buckets. You can do this in any of the following ways: diff --git a/docs/source/teams/overview.rst b/docs/source/teams/overview.rst index 4ff008c681..c63dbfca54 100644 --- a/docs/source/teams/overview.rst +++ b/docs/source/teams/overview.rst @@ -115,7 +115,7 @@ would with open source FiftyOne: - | :ref:`Exporting data for model training ` | `Adding model predictions to FiftyOne `_ | :ref:`Evaluating models in FiftyOne ` - | :ref:`Using interactice plots to explore results ` + | :ref:`Using interactive plots to explore results ` .. _teams-system-architecture: diff --git a/fiftyone/core/collections.py b/fiftyone/core/collections.py index 0619199755..cdcc194bc2 100644 --- a/fiftyone/core/collections.py +++ b/fiftyone/core/collections.py @@ -788,6 +788,10 @@ def one(self, expr, exact=False): exact (False): whether to raise an error if multiple samples match the expression + Raises: + ValueError: if no samples match the expression or if ``exact=True`` + and multiple samples match the expression + Returns: a :class:`fiftyone.core.sample.SampleView` """ @@ -1620,7 +1624,13 @@ def _edit_label_tags( label_ids = self.values(id_path) for _label_ids in fou.iter_batches(label_ids, 100000): - ops.append(UpdateMany({_id_path: {"$in": _label_ids}}, update)) + _label_ids = [_id for _id in _label_ids if _id is not None] + ops.append( + UpdateMany( + {_id_path: {"$in": _label_ids}}, + update, + ) + ) if ops: self._dataset._bulk_write(ops, frames=is_frame_field) @@ -9777,7 +9787,7 @@ def _make_set_field_pipeline( self, field, expr, - embedded_root, + embedded_root=embedded_root, allow_missing=allow_missing, new_field=new_field, context=context, @@ -10357,7 +10367,9 @@ def _parse_field_name( other_list_fields = sorted(other_list_fields) def _replace(path): - return ".".join([new_field] + path.split(".")[1:]) + n = new_field.count(".") + 1 + chunks = path.split(".", n) + return new_field + "." + chunks[-1] if len(chunks) > n else new_field if new_field: field_name = _replace(field_name) @@ -10448,7 +10460,7 @@ def _make_set_field_pipeline( sample_collection, field, expr, - embedded_root, + embedded_root=False, allow_missing=False, new_field=None, context=None, diff --git a/fiftyone/core/dataset.py b/fiftyone/core/dataset.py index c42cce62ae..b15b9d7a8b 100644 --- a/fiftyone/core/dataset.py +++ b/fiftyone/core/dataset.py @@ -2679,6 +2679,108 @@ def _merge_doc( overwrite_info=overwrite_info, ) + def merge_sample( + self, + sample, + key_field="filepath", + skip_existing=False, + insert_new=True, + fields=None, + omit_fields=None, + merge_lists=True, + overwrite=True, + expand_schema=True, + validate=True, + dynamic=False, + ): + """Merges the fields of the given sample into this dataset. + + By default, the sample is merged with an existing sample with the same + absolute ``filepath``, if one exists. Otherwise a new sample is + inserted. You can customize this behavior via the ``key_field``, + ``skip_existing``, and ``insert_new`` parameters. + + The behavior of this method is highly customizable. By default, all + top-level fields from the provided sample are merged in, overwriting + any existing values for those fields, with the exception of list fields + (e.g., ``tags``) and label list fields (e.g., + :class:`fiftyone.core.labels.Detections` fields), in which case the + elements of the lists themselves are merged. In the case of label list + fields, labels with the same ``id`` in both samples are updated rather + than duplicated. + + To avoid confusion between missing fields and fields whose value is + ``None``, ``None``-valued fields are always treated as missing while + merging. + + This method can be configured in numerous ways, including: + + - Whether new fields can be added to the dataset schema + - Whether list fields should be treated as ordinary fields and merged + as a whole rather than merging their elements + - Whether to merge only specific fields, or all but certain fields + - Mapping input sample fields to different field names of this sample + + Args: + sample: a :class:`fiftyone.core.sample.Sample` + key_field ("filepath"): the sample field to use to decide whether + to join with an existing sample + skip_existing (False): whether to skip existing samples (True) or + merge them (False) + insert_new (True): whether to insert new samples (True) or skip + them (False) + fields (None): an optional field or iterable of fields to which to + restrict the merge. May contain frame fields for video samples. + This can also be a dict mapping field names of the input sample + to field names of this dataset + omit_fields (None): an optional field or iterable of fields to + exclude from the merge. May contain frame fields for video + samples + merge_lists (True): whether to merge the elements of list fields + (e.g., ``tags``) and label list fields (e.g., + :class:`fiftyone.core.labels.Detections` fields) rather than + merging the entire top-level field like other field types. + For label lists fields, existing + :class:`fiftyone.core.label.Label` elements are either replaced + (when ``overwrite`` is True) or kept (when ``overwrite`` is + False) when their ``id`` matches a label from the provided + sample + overwrite (True): whether to overwrite (True) or skip (False) + existing fields and label elements + expand_schema (True): whether to dynamically add new fields + encountered to the dataset schema. If False, an error is raised + if any fields are not in the dataset schema + validate (True): whether to validate values for existing fields + dynamic (False): whether to declare dynamic embedded document + fields + """ + try: + F = foe.ViewField + existing_sample = self.one(F(key_field) == sample[key_field]) + except ValueError: + if insert_new: + self.add_sample( + sample, + expand_schema=expand_schema, + dynamic=dynamic, + validate=validate, + ) + + return + + if not skip_existing: + existing_sample.merge( + sample, + fields=fields, + omit_fields=omit_fields, + merge_lists=merge_lists, + overwrite=overwrite, + expand_schema=expand_schema, + validate=validate, + dynamic=dynamic, + ) + existing_sample.save() + def merge_samples( self, samples, diff --git a/fiftyone/core/odm/mixins.py b/fiftyone/core/odm/mixins.py index 681e9faaea..ba53df04a4 100644 --- a/fiftyone/core/odm/mixins.py +++ b/fiftyone/core/odm/mixins.py @@ -993,6 +993,19 @@ def _merge_field(cls, path, field, validate=True, recursive=True): ): return {path: field} + # + # In principle, merging an untyped list field into a typed list + # field --- eg ListField(StringField) --- should not be allowed, as + # the untyped list may contain incompatible values. + # + # However, we are intentionally skipping validation here so that + # the following will work:: + # + # doc.set_field("tags", [], validate=True) + # + if is_list_field and field is None: + return + if validate: validate_fields_match(path, field, existing_field) diff --git a/fiftyone/core/session/client.py b/fiftyone/core/session/client.py index d75b8e78c1..c791aac210 100644 --- a/fiftyone/core/session/client.py +++ b/fiftyone/core/session/client.py @@ -9,7 +9,7 @@ from dataclasses import asdict, dataclass import logging from retrying import retry -from threading import Thread +from threading import Thread, Event as ThreadEvent import time import typing as t @@ -49,15 +49,27 @@ class Client: def __post_init__(self) -> None: self._subscription = str(uuid4()) self._connected = True + self._closed = ThreadEvent() + self._closed.set() self._listeners: t.Dict[str, t.Set[t.Callable]] = defaultdict(set) - def run(self, state: fos.StateDescription) -> None: - """Runs the client subscription in a background thread + @property + def origin(self) -> str: + """The origin of the server""" + return f"http://{self.address}:{self.port}" + + @property + def is_open(self) -> str: + """Whether the client is connected""" + return not self._closed.is_set() + + def open(self, state: fos.StateDescription) -> None: + """Open the client connection Arg: state: the initial state description """ - if hasattr(self, "_thread"): + if not self._closed.is_set(): raise RuntimeError("Client is already running") def run_client() -> None: @@ -97,23 +109,27 @@ def subscribe() -> None: self._connected = True subscribe() except Exception as e: - if foc.DEV_INSTALL: + if logger.level == logging.DEBUG: raise e - self._connected = False - print( - "\r\nCould not connect session, trying again " - "in 10 seconds\r\n" - ) - time.sleep(10) + if self._closed.is_set(): + break + + self._connected = False + print( + "\r\nCould not connect session, trying again " + "in 10 seconds\r\n" + ) + time.sleep(10) self._thread = Thread(target=run_client, daemon=True) self._thread.start() - @property - def origin(self) -> str: - """The origin of the server""" - return f"http://{self.address}:{self.port}" + def close(self): + """Close the client connection""" + self._closed.set() + self._thread.join(timeout=0) + self._thread = None def send_event(self, event: EventType) -> None: """Sends an event to the server @@ -121,8 +137,12 @@ def send_event(self, event: EventType) -> None: Args: event: the event """ + if self._closed.is_set(): + return + if not self._connected: raise RuntimeError("Client is not connected") + self._post_event(event) self._dispatch_event(event) @@ -156,6 +176,9 @@ def _dispatch_event(self, event: EventType) -> None: listener(event) def _post_event(self, event: Event) -> None: + if self._closed.is_set(): + return + response = requests.post( f"{self.origin}/event", headers={"Content-type": "application/json"}, diff --git a/fiftyone/core/session/session.py b/fiftyone/core/session/session.py index 84a74c38ba..232482be81 100644 --- a/fiftyone/core/session/session.py +++ b/fiftyone/core/session/session.py @@ -422,7 +422,7 @@ def __init__( remote=remote, start_time=self._get_time(), ) - self._client.run(self._state) + self._client.open(self._state) _attach_listeners(self) _register_session(self) @@ -515,6 +515,7 @@ def __del__(self) -> None: # logger may already have been garbage-collected print(_WAIT_INSTRUCTIONS) + self._client.close() _unregister_session(self) except: # e.g. globals were already garbage-collected @@ -970,9 +971,10 @@ def open(self) -> None: - Desktop: opens the desktop App, if necessary - Other (non-remote): opens the App in a new browser tab """ - if self.remote: - logger.warning("Remote sessions cannot open new App windows") - return + _register_session(self) + + if not self._client.is_open: + self._client.open(self._state) if self.plots: self.plots.connect() @@ -1102,13 +1104,14 @@ def wait(self, wait: float = 3) -> None: def close(self) -> None: """Closes the session and terminates the App, if necessary.""" - if self.remote: - return + if self.desktop: + self._app_service.stop() - if self._client._connected and focx._get_context() == focx._NONE: - self._client.send_event(CloseSession()) + if self._client.is_open and focx.is_notebook_context(): + self.freeze() self.plots.disconnect() + self.__del__() def freeze(self) -> None: """Screenshots the active App cell, replacing it with a static image. diff --git a/fiftyone/core/stages.py b/fiftyone/core/stages.py index a18e6ef371..5c05bab244 100644 --- a/fiftyone/core/stages.py +++ b/fiftyone/core/stages.py @@ -1791,6 +1791,7 @@ def to_mongo(self, sample_collection): if is_frame_field: return _get_filter_frames_field_pipeline( + sample_collection, field_name, new_field, self._filter, @@ -1798,6 +1799,7 @@ def to_mongo(self, sample_collection): ) return _get_filter_field_pipeline( + sample_collection, field_name, new_field, self._filter, @@ -1875,6 +1877,7 @@ def validate(self, sample_collection): def _get_filter_field_pipeline( + sample_collection, filter_field, new_field, filter_arg, @@ -1908,25 +1911,40 @@ def _get_field_only_matches_expr(field): def _get_filter_frames_field_pipeline( + sample_collection, filter_field, new_field, filter_arg, only_matches=True, ): cond = _get_field_mongo_filter(filter_arg, prefix="$frame." + filter_field) - merge = { - "$cond": { - "if": cond, - "then": "$$frame." + filter_field, - "else": None, - } - } if "." in new_field: - parent, child = new_field.split(".") - obj = {parent: {child: merge}} + parent, child = new_field.split(".", 1) + obj = { + "$cond": { + "if": {"$gt": ["$$frame." + filter_field, None]}, + "then": { + "$cond": { + "if": cond, + "then": {parent: {child: "$$frame." + filter_field}}, + "else": None, + } + }, + "else": {}, + }, + } + else: - obj = {new_field: merge} + obj = { + new_field: { + "$cond": { + "if": cond, + "then": "$$frame." + filter_field, + "else": None, + } + } + } pipeline = [ { @@ -2333,6 +2351,7 @@ def to_mongo(self, sample_collection): _make_filter_pipeline = _get_filter_field_pipeline filter_pipeline = _make_filter_pipeline( + sample_collection, labels_field, new_field, label_filter, @@ -2433,6 +2452,7 @@ def validate(self, sample_collection): def _get_filter_list_field_pipeline( + sample_collection, filter_field, new_field, filter_arg, @@ -2464,19 +2484,31 @@ def _get_list_field_only_matches_expr(field): def _get_filter_frames_list_field_pipeline( + sample_collection, filter_field, new_field, filter_arg, only_matches=True, ): cond = _get_list_field_mongo_filter(filter_arg) - label_field, labels_list = new_field.split(".")[-2:] - old_field = filter_field.split(".")[0] + parent, leaf = filter_field.split(".", 1) + new_parent, _ = new_field.split(".", 1) + if not issubclass( + sample_collection.get_field(f"frames.{parent}").document_type, + fol.Label, + ): + label_field, labels_list = leaf.split(".") + obj = lambda merge: {new_parent: {label_field: merge}} + label_path = f"{parent}.{label_field}" + else: + label_field, labels_list = new_parent, leaf + label_path = label_field + obj = lambda merge: {label_field: merge} merge = { "$mergeObjects": [ - "$$frame." + old_field, + "$$frame." + label_path, { labels_list: { "$filter": { @@ -2487,11 +2519,6 @@ def _get_filter_frames_list_field_pipeline( }, ] } - if "." in label_field: - parent, label_field = label_field.split(".") - obj = {parent: {label_field: merge}} - else: - obj = {label_field: merge} pipeline = [ { @@ -2500,7 +2527,7 @@ def _get_filter_frames_list_field_pipeline( "$map": { "input": "$frames", "as": "frame", - "in": {"$mergeObjects": ["$$frame", obj]}, + "in": {"$mergeObjects": ["$$frame", obj(merge)]}, } } } @@ -2706,7 +2733,6 @@ def to_mongo(self, sample_collection): _, points_path = sample_collection._get_label_field_path( self._field, "points" ) - new_field = self._get_new_field(sample_collection) pipeline = [] @@ -2781,25 +2807,17 @@ def to_mongo(self, sample_collection): if self._only_matches: # Remove Keypoint objects with no points after filtering + has_points = ( + F("points").filter(F()[0] != float("nan")).length() > 0 + ) if is_list_field: - has_points = ( - F("points").filter(F()[0] != float("nan")).length() > 0 - ) - match_expr = F("keypoints").filter(has_points) + only_expr = F().filter(has_points) else: - field, _ = sample_collection._handle_frame_field(new_field) - has_points = ( - F(field + ".points") - .filter(F()[0] != float("nan")) - .length() - > 0 - ) - match_expr = has_points.if_else(F(field), None) + only_expr = has_points.if_else(F(), None) _pipeline, _ = sample_collection._make_set_field_pipeline( root_path, - match_expr, - embedded_root=True, + only_expr, allow_missing=True, new_field=self._new_field, ) diff --git a/fiftyone/server/query.py b/fiftyone/server/query.py index 21bc66d89f..29dea9df70 100644 --- a/fiftyone/server/query.py +++ b/fiftyone/server/query.py @@ -8,6 +8,7 @@ from dataclasses import asdict from datetime import date, datetime from enum import Enum +import logging import os import typing as t @@ -524,7 +525,13 @@ def _flatten_fields( ) -> t.List[t.Dict]: result = [] for field in fields: - key = field.pop("name") + key = field.pop("name", None) + if key is None: + # Issues with concurrency can cause this to happen. + # Until it's fixed, just ignore these fields to avoid throwing hard + # errors when loading in the app. + logging.debug("Skipping field with no name: %s", field) + continue field_path = path + [key] field["path"] = ".".join(field_path) result.append(field) diff --git a/fiftyone/server/samples.py b/fiftyone/server/samples.py index 5918e116a7..6e2d0d0c83 100644 --- a/fiftyone/server/samples.py +++ b/fiftyone/server/samples.py @@ -78,7 +78,6 @@ async def paginate_samples( stages=stages, filters=filters, pagination_data=pagination_data, - count_label_tags=True, extended_stages=extended_stages, sample_filter=sample_filter, reload=reload, diff --git a/fiftyone/server/view.py b/fiftyone/server/view.py index d88b75836f..778bf92831 100644 --- a/fiftyone/server/view.py +++ b/fiftyone/server/view.py @@ -77,7 +77,6 @@ def get_view( stages=None, filters=None, pagination_data=False, - count_label_tags=False, extended_stages=None, sample_filter=None, reload=True, @@ -90,8 +89,6 @@ def get_view( stages (None): an optional list of serialized :class:`fiftyone.core.stages.ViewStage` instances filters (None): an optional ``dict`` of App defined filters - count_label_tags (False): whether to includes hidden ``_label_tags`` - counts on sample documents pagination_data (False): whether process samples as pagination data - excludes all :class:`fiftyone.core.fields.DictField` values - filters label fields @@ -134,11 +131,10 @@ def get_view( # omit all dict field values for performance, not needed by grid view = _project_pagination_paths(view) - if filters or extended_stages: + if filters or extended_stages or pagination_data: view = get_extended_view( view, filters, - count_label_tags=count_label_tags, pagination_data=pagination_data, extended_stages=extended_stages, ) @@ -149,7 +145,6 @@ def get_view( def get_extended_view( view, filters=None, - count_label_tags=False, extended_stages=None, pagination_data=False, ): @@ -158,8 +153,6 @@ def get_extended_view( Args: view: a :class:`fiftyone.core.collections.SampleCollection` filters: an optional ``dict`` of App defined filters - count_label_tags (False): whether to set the hidden ``_label_tags`` - field with counts of tags with respect to all label fields extended_stages (None): extended view stages pagination_data (False): filters label data @@ -187,17 +180,12 @@ def get_extended_view( if label_tags: view = _match_label_tags(view, label_tags) - stages = _make_filter_stages( - view, - filters, - count_label_tags=count_label_tags, - pagination_data=pagination_data, - ) + stages = _make_filter_stages(view, filters) for stage in stages: view = view.add_stage(stage) - if count_label_tags: + if pagination_data: view = _add_labels_tags_counts(view) return view @@ -272,85 +260,83 @@ def _project_pagination_paths(view: foc.SampleCollection): def _make_filter_stages( view, filters, - count_label_tags=False, - pagination_data=False, ): stages = [] queries = [] - for path, field, prefix, args in _iter_paths(view, filters): + for path, label_path, field, args in _iter_paths(view, filters): is_matching = args.get("isMatching", True) - only_matches = args.get("onlyMatch", True) - + path_field = view.get_field(path) is_label_field = _is_label(field) - if is_label_field and issubclass( - field.document_type, (fol.Keypoint, fol.Keypoints) + if ( + is_label_field + and issubclass(field.document_type, (fol.Keypoint, fol.Keypoints)) + and isinstance(path_field, (fof.KeypointsField, fof.ListField)) ): continue - if not is_label_field or (is_matching or only_matches): - queries.append(_make_query(path, view.get_field(path), args)) + if args.get("exclude") and not is_matching: + continue + + queries.append(_make_query(path, path_field, args)) if queries: stages.append(fosg.Match({"$and": queries})) - for path, field, prefix, args in _iter_paths(view, filters): - if not _is_label(field): - continue - + for path, label_path, label_field, args in _iter_paths( + view, filters, labels=True + ): is_matching = args.get("isMatching", True) - only_matches = args.get("onlyMatch", True) - parent = field field = view.get_field(path) - if issubclass(parent.document_type, (fol.Keypoint, fol.Keypoints)): + if issubclass( + label_field.document_type, (fol.Keypoint, fol.Keypoints) + ) and isinstance(field, fof.ListField): expr = _make_keypoint_list_filter(args, view, path, field) if expr is not None: stages.append( fosg.FilterKeypoints( - prefix + parent.name, - only_matches=only_matches, + label_path, + only_matches=True, **expr, ) ) - elif not is_matching and (pagination_data or not count_label_tags): + elif not is_matching: key = field.db_field if field.db_field else field.name expr = _make_scalar_expression(F(key), args, field, is_label=True) if expr is not None: stages.append( fosg.FilterLabels( - prefix + parent.name, + label_path, expr, - only_matches=False, + only_matches=not args.get("exclude", False), ) ) return stages -def _iter_paths(view, filters): - field_schema = view.get_field_schema() - if view.media_type != fom.IMAGE: - frame_field_schema = view.get_frame_field_schema() - else: - frame_field_schema = None - +def _iter_paths(view, filters, labels=False): for path in sorted(filters): if path == "tags" or path.startswith("_"): continue - args = filters[path] - frames = path.startswith(view._FRAMES_PREFIX) - keys = path.split(".") - if frames: - field = frame_field_schema[keys[1]] - keys = keys[2:] - prefix = "frames." + if "." in path: + parent_path = ".".join(path.split(".")[:-1]) else: - field = field_schema[keys[0]] - keys = keys[1:] - prefix = "" + parent_path = path - yield path, field, prefix, args + parent_field = view.get_field(parent_path) + if isinstance(parent_field, fof.ListField) and isinstance( + parent_field.field, fof.EmbeddedDocumentField + ): + if issubclass(parent_field.field.document_type, fol.Label): + parent_path = ".".join(parent_path.split(".")[:-1]) + parent_field = view.get_field(parent_path) + + if labels and not _is_label(parent_field): + continue + + yield path, parent_path, parent_field, filters[path] def _is_support(field): @@ -378,11 +364,14 @@ def _is_label(field): def _make_query(path, field, args): - if isinstance(field, fof.ListField): + keys = path.split(".") + path = ".".join(keys[:-1] + [field.db_field or field.name]) + if isinstance(field, fof.ListField) and field.field: field = field.field if isinstance( - field, (fof.DateField, fof.DateTimeField, fof.FloatField, fof.IntField) + field, + (fof.DateField, fof.DateTimeField, fof.FloatField, fof.IntField), ): mn, mx = args["range"] if isinstance(field, (fof.DateField, fof.DateTimeField)): @@ -403,7 +392,7 @@ def _make_query(path, field, args): ] } - values = args["values"] + values = args.get("values", None) if isinstance(field, fof.ObjectIdField): values = list(map(lambda v: ObjectId(v), args["values"])) @@ -418,10 +407,10 @@ def _make_query(path, field, args): } if not true and false: - return {path: {"$neq" if args["exclude"] else "$eq": False}} + return {path: {"$ne" if args["exclude"] else "$eq": False}} if true and not false: - return {path: {"$neq" if args["exclude"] else "$eq": True}} + return {path: {"$ne" if args["exclude"] else "$eq": True}} if not true and not false: return { @@ -474,6 +463,10 @@ def _make_scalar_expression(f, args, field, list_field=False, is_label=False): expr = f.is_in(values) exclude = args["exclude"] + if exclude: + # pylint: disable=invalid-unary-operand-type + expr = ~expr + if none and not is_label and not list_field: if exclude: expr &= f.exists() @@ -681,18 +674,19 @@ def _match_label_tags(view: foc.SampleCollection, label_tags): matching = label_tags["isMatching"] expr = lambda exclude, values: {"$nin" if exclude else "$in": values} - view = view.mongo( - [ - { - "$match": { - "$or": [ - {f"{path}.tags": expr(exclude, values)} - for path in label_paths - ] + if not exclude or matching: + view = view.mongo( + [ + { + "$match": { + "$or": [ + {f"{path}.tags": expr(exclude, values)} + for path in label_paths + ] + } } - } - ] - ) + ] + ) if not matching and exclude: view = view.exclude_labels( diff --git a/package/desktop/setup.py b/package/desktop/setup.py index f0d0b6bb5d..248f511adf 100644 --- a/package/desktop/setup.py +++ b/package/desktop/setup.py @@ -16,7 +16,7 @@ import shutil -VERSION = "0.28.1" +VERSION = "0.28.2" def get_version(): diff --git a/setup.py b/setup.py index 90533623ee..826a6ba39a 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ from setuptools import setup, find_packages -VERSION = "0.21.2" +VERSION = "0.21.3" def get_version(): @@ -73,7 +73,7 @@ def get_version(): "xmltodict", "universal-analytics-python3>=1.0.1,<2", # internal packages - "fiftyone-brain>=0.12,<0.13", + "fiftyone-brain>=0.13,<0.14", "fiftyone-db>=0.4,<0.5", "voxel51-eta>=0.10,<0.11", ] @@ -112,7 +112,7 @@ def get_install_requirements(install_requires, choose_install_requires): return install_requires -EXTRAS_REQUIREMENTS = {"desktop": ["fiftyone-desktop>=0.28.1,<0.29"]} +EXTRAS_REQUIREMENTS = {"desktop": ["fiftyone-desktop>=0.28.2,<0.29"]} with open("README.md", "r") as fh: diff --git a/tests/unittests/dataset_tests.py b/tests/unittests/dataset_tests.py index a95c42365e..ebb45e43bb 100644 --- a/tests/unittests/dataset_tests.py +++ b/tests/unittests/dataset_tests.py @@ -1119,6 +1119,86 @@ def test_add_list_subfield(self): self.assertIsInstance(field.field, fo.EmbeddedDocumentField) self.assertEqual(field.field.document_type, fo.DynamicEmbeddedDocument) + @drop_datasets + def test_one(self): + samples = [ + fo.Sample(filepath="image1.jpg"), + fo.Sample(filepath="image2.png"), + fo.Sample(filepath="image3.jpg"), + ] + + dataset = fo.Dataset() + dataset.add_samples(samples) + + filepath = dataset.first().filepath + + sample = dataset.one(F("filepath") == filepath) + + self.assertEqual(sample.filepath, filepath) + + with self.assertRaises(ValueError): + _ = dataset.one(F("filepath") == "bad") + + sample = dataset.one(F("filepath").ends_with(".jpg")) + + self.assertTrue(sample.filepath.endswith(".jpg")) + + with self.assertRaises(ValueError): + _ = dataset.one(F("filepath").ends_with(".jpg"), exact=True) + + @drop_datasets + def test_merge_sample(self): + sample1 = fo.Sample(filepath="image.jpg", foo="bar", tags=["a"]) + sample2 = fo.Sample(filepath="image.jpg", spam="eggs", tags=["b"]) + sample3 = fo.Sample(filepath="image.jpg", tags=[]) + + # No dataset + + s1 = sample1.copy() + s2 = sample2.copy() + s3 = sample3.copy() + + s1.merge(s2) + s1.merge(s3) + + self.assertListEqual(s1["tags"], ["a", "b"]) + self.assertEqual(s1["foo"], "bar") + self.assertEqual(s1["spam"], "eggs") + + # In dataset + + s1 = sample1.copy() + s2 = sample2.copy() + s3 = sample3.copy() + + dataset = fo.Dataset() + dataset.add_sample(s1) + + dataset.merge_sample(s2) + dataset.merge_sample(s3) + + self.assertListEqual(s1["tags"], ["a", "b"]) + self.assertEqual(s1["foo"], "bar") + self.assertEqual(s1["spam"], "eggs") + + # List merging variations + + s1 = sample1.copy() + s2 = sample2.copy() + s3 = sample3.copy() + + dataset = fo.Dataset() + dataset.add_sample(s1) + + dataset.merge_sample(s2, merge_lists=False) + + self.assertListEqual(s1["tags"], ["b"]) + + # Tests an edge case when setting a typed list field to an empty list + dataset.merge_sample(s3, merge_lists=False, dynamic=True) + + self.assertListEqual(s1["tags"], []) + @drop_datasets def test_merge_samples1(self): # Windows compatibility @@ -4995,11 +5075,10 @@ def test_from_images(self): fo.Dataset.from_images(filepaths, name=dataset.name) dataset2 = fo.Dataset.from_images( - filepaths, name=dataset.name, overwrite=True, persistent=True + filepaths, name=dataset.name, overwrite=True ) self.assertEqual(len(dataset2), 1) - self.assertTrue(dataset2.persistent) samples = [{"filepath": "image.jpg"}] sample_parser = _ImageSampleParser() @@ -5019,11 +5098,9 @@ def test_from_images(self): sample_parser=sample_parser, name=dataset.name, overwrite=True, - persistent=True, ) self.assertEqual(len(dataset2), 1) - self.assertTrue(dataset2.persistent) @drop_datasets def test_from_videos(self): @@ -5036,11 +5113,10 @@ def test_from_videos(self): fo.Dataset.from_videos(filepaths, name=dataset.name) dataset2 = fo.Dataset.from_videos( - filepaths, name=dataset.name, overwrite=True, persistent=True + filepaths, name=dataset.name, overwrite=True ) self.assertEqual(len(dataset2), 1) - self.assertTrue(dataset2.persistent) samples = [{"filepath": "video.mp4"}] sample_parser = _VideoSampleParser() @@ -5060,11 +5136,9 @@ def test_from_videos(self): sample_parser=sample_parser, name=dataset.name, overwrite=True, - persistent=True, ) self.assertEqual(len(dataset2), 1) - self.assertTrue(dataset2.persistent) @drop_datasets def test_from_labeled_images(self): @@ -5090,11 +5164,9 @@ def test_from_labeled_images(self): label_field="ground_truth", name=dataset.name, overwrite=True, - persistent=True, ) self.assertEqual(dataset2.values("ground_truth.label"), ["label"]) - self.assertTrue(dataset2.persistent) @drop_datasets def test_from_labeled_videos(self): @@ -5120,11 +5192,9 @@ def test_from_labeled_videos(self): label_field="ground_truth", name=dataset.name, overwrite=True, - persistent=True, ) self.assertEqual(dataset2.values("ground_truth.label"), ["label"]) - self.assertTrue(dataset2.persistent) class _ImageSampleParser(foud.ImageSampleParser): diff --git a/tests/unittests/label_tests.py b/tests/unittests/label_tests.py index 36f7d99509..efb922b4d7 100644 --- a/tests/unittests/label_tests.py +++ b/tests/unittests/label_tests.py @@ -15,6 +15,9 @@ from decorators import drop_datasets +F = fo.ViewField + + class LabelTests(unittest.TestCase): @drop_datasets def test_id(self): @@ -187,6 +190,155 @@ def test_dynamic_label_tags(self): {}, ) + @drop_datasets + def test_dynamic_frame_label_fields(self): + dynamic_doc = fo.DynamicEmbeddedDocument( + classification=fo.Classification(label="label"), + classifications=fo.Classifications( + classifications=[fo.Classification(label="label")] + ), + ) + sample = fo.Sample(filepath="video.mp4") + sample.frames[1]["dynamic"] = dynamic_doc + + dataset = fo.Dataset() + dataset.add_sample(sample) + dataset.add_dynamic_frame_fields() + + label_id = dynamic_doc["classification"].id + view = dataset.select_labels( + [ + { + "label_id": label_id, + "sample_id": sample.id, + "frame_number": 1, + "field": "frames.dynamic.classification", + } + ] + ) + dynamic = view.first().frames[1].dynamic + self.assertTrue(label_id == dynamic.classification.id) + self.assertFalse("classifications" in dynamic) + + view = dataset.filter_labels( + "frames.dynamic.classification", F("label") == "label" + ) + dynamic = view.first().frames[1].dynamic + self.assertTrue(label_id == dynamic.classification.id) + + label_id = dynamic_doc["classifications"].classifications[0].id + view = dataset.select_labels( + [ + { + "label_id": label_id, + "sample_id": sample.id, + "frame_number": 1, + "field": "frames.dynamic.classifications", + } + ] + ) + dynamic = view.first().frames[1].dynamic + self.assertTrue( + label_id == dynamic.classifications.classifications[0].id + ) + self.assertFalse("classification" in dynamic) + + view = dataset.filter_labels( + "frames.dynamic.classifications", F("label") == "label" + ) + dynamic = view.first().frames[1].dynamic + self.assertTrue( + label_id == dynamic.classifications.classifications[0].id + ) + + @drop_datasets + def test_dynamic_frame_label_tags(self): + sample1 = fo.Sample( + filepath="video1.mp4", + ) + sample1.frames[1]["dynamic"] = fo.DynamicEmbeddedDocument( + classification=fo.Classification(label="hi"), + classifications=fo.Classifications( + classifications=[ + fo.Classification(label="spam"), + fo.Classification(label="eggs"), + ] + ), + ) + # test with empty documents + sample1.frames[2]["dynamic"] = fo.DynamicEmbeddedDocument() + + sample2 = fo.Sample(filepath="video2.mp4") + + dataset = fo.Dataset() + dataset.add_samples([sample1, sample2], dynamic=True) + + label_fields = set(dataset._get_label_fields()) + + self.assertSetEqual( + label_fields, + { + "frames.dynamic.classification", + "frames.dynamic.classifications", + }, + ) + + self.assertDictEqual(dataset.count_label_tags(), {}) + + dataset.tag_labels("test") + self.assertDictEqual(dataset.count_label_tags(), {"test": 3}) + self.assertDictEqual( + dataset.count_label_tags( + label_fields="frames.dynamic.classification" + ), + {"test": 1}, + ) + self.assertDictEqual( + dataset.count_label_tags( + label_fields="frames.dynamic.classifications" + ), + {"test": 2}, + ) + + dataset.untag_labels("test") + self.assertDictEqual(dataset.count_label_tags(), {}) + + dataset.tag_labels( + "test", label_fields="frames.dynamic.classification" + ) + self.assertDictEqual(dataset.count_label_tags(), {"test": 1}) + self.assertDictEqual( + dataset.count_label_tags( + label_fields="frames.dynamic.classification" + ), + {"test": 1}, + ) + + dataset.untag_labels("test") + self.assertDictEqual(dataset.count_label_tags(), {}) + + dataset.tag_labels( + "test", label_fields="frames.dynamic.classifications" + ) + self.assertDictEqual(dataset.count_label_tags(), {"test": 2}) + self.assertDictEqual( + dataset.count_label_tags( + label_fields="frames.dynamic.classifications" + ), + {"test": 2}, + ) + + dataset.untag_labels( + "test", label_fields="frames.dynamic.classifications" + ) + self.assertDictEqual(dataset.count_label_tags(), {}) + self.assertDictEqual( + dataset.count_label_tags( + label_fields="frames.dynamic.classifications" + ), + {}, + ) + if __name__ == "__main__": fo.config.show_progress_bars = False diff --git a/tests/unittests/server_tests.py b/tests/unittests/server_tests.py index 5d2169b851..88f1ea164c 100644 --- a/tests/unittests/server_tests.py +++ b/tests/unittests/server_tests.py @@ -5,11 +5,15 @@ | `voxel51.com `_ | """ +import math import unittest +import fiftyone as fo import fiftyone.core.dataset as fod import fiftyone.core.fields as fof import fiftyone.core.labels as fol +import fiftyone.core.odm as foo +import fiftyone.core.sample as fos import fiftyone.server.view as fosv from decorators import drop_datasets @@ -17,645 +21,761 @@ class ServerViewTests(unittest.TestCase): @drop_datasets - def test_extended_view_image_label_filters_samples(self): + def test_extended_image_sample(self): + dataset = fod.Dataset("test") + sample = fos.Sample( + filepath="image.png", + predictions=fol.Detections( + detections=[ + fol.Detection( + label="carrot", confidence=0.25, tags=["one", "two"] + ), + fol.Detection( + label="not_carrot", confidence=0.75, tags=["two"] + ), + ] + ), + bool=True, + int=1, + str="str", + list_bool=[True], + list_int=[1, 2], + list_str=["one", "two"], + ) + dataset.add_sample(sample) + filters = { + "id": { + "values": [dataset.first().id], + "exclude": False, + } + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) + + filters = { + "id": { + "values": [dataset.first().id], + "exclude": True, + } + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 0) + filters = { "predictions.detections.label": { "values": ["carrot"], "exclude": False, - "onlyMatch": True, "isMatching": False, - "_CLS": "str", }, "predictions.detections.confidence": { "range": [0.5, 1], - "_CLS": "numeric", "exclude": False, - "onlyMatch": True, "isMatching": False, }, } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 0) - dataset = fod.Dataset("test") - dataset.add_sample_field( - "predictions", fof.EmbeddedDocumentField, fol.Detections - ) - - returned = fosv.get_view( - "test", - filters=filters, - count_label_tags=True, - )._pipeline() - - expected = [ - { - "$match": { - "$and": [ - { - "$and": [ - { - "predictions.detections.confidence": { - "$gte": 0.5 - } - }, - { - "predictions.detections.confidence": { - "$lte": 1 - } - }, - ], - }, - {"predictions.detections.label": {"$in": ["carrot"]}}, - ], - }, - }, - {"$addFields": {"_label_tags": []}}, - { - "$addFields": { - "_label_tags": { - "$cond": { - "if": {"$gt": ["$predictions", None]}, - "then": { - "$concatArrays": [ - "$_label_tags", - { - "$reduce": { - "input": "$predictions.detections", - "initialValue": [], - "in": { - "$concatArrays": [ - "$$value", - "$$this.tags", - ], - }, - }, - }, - ], - }, - "else": "$_label_tags", - }, - }, - }, - }, - { - "$addFields": { - "_label_tags": { - "$function": { - "body": "function(items) {let counts = {};items && items.forEach((i) => {counts[i] = 1 + (counts[i] || 0);});return counts;}", - "args": ["$_label_tags"], - "lang": "js", - }, - }, - }, - }, - ] - - self.assertEqual(expected, returned) - - @drop_datasets - def test_extended_view_image_label_filters_aggregations(self): filters = { "predictions.detections.label": { "values": ["carrot"], "exclude": False, - "onlyMatch": True, "isMatching": False, - "_CLS": "str", }, "predictions.detections.confidence": { - "range": [0.5, 1], - "_CLS": "numeric", + "range": [0.0, 0.5], "exclude": False, - "onlyMatch": True, "isMatching": False, }, } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) + self.assertEqual(len(view.first().predictions.detections), 1) - dataset = fod.Dataset("test") - dataset.add_sample_field( - "predictions", fof.EmbeddedDocumentField, fol.Detections + filters = { + "list_str": { + "values": ["one"], + "exclude": False, + }, + "list_int": { + "range": [0, 2], + "exclude": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) + self.assertEqual(len(view.first().list_str), 2) + self.assertEqual(len(view.first().list_int), 2) + + filters = { + "list_str": { + "values": ["empty"], + "exclude": False, + }, + "list_int": { + "range": [0, 2], + "exclude": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 0) + + filters = { + "list_str": { + "values": ["one"], + "exclude": False, + }, + "list_int": { + "range": [3, 4], + "exclude": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 0) + + filters = { + "list_bool": { + "true": False, + "false": True, + "exclude": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 0) + + filters = { + "list_bool": { + "true": False, + "false": True, + "exclude": True, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) + + filters = { + "list_bool": { + "true": True, + "false": False, + "exclude": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) + + view = fosv.get_view("test", pagination_data=True) + (sample,) = list( + foo.aggregate( + foo.get_db_conn()[view._dataset._sample_collection_name], + view._pipeline(), + ) ) + self.assertIn("_label_tags", sample) + self.assertDictEqual(sample["_label_tags"], {"one": 1, "two": 2}) - returned = fosv.get_view( - "test", filters=filters, count_label_tags=False - )._pipeline() - - expected = [ - { - "$match": { - "$and": [ - { - "$and": [ - { - "predictions.detections.confidence": { - "$gte": 0.5 - } - }, - { - "predictions.detections.confidence": { - "$lte": 1 - } - }, - ], - }, - {"predictions.detections.label": {"$in": ["carrot"]}}, - ], - }, - }, - { - "$addFields": { - "predictions.detections": { - "$filter": { - "input": "$predictions.detections", - "cond": { - "$or": [ - { - "$and": [ - { - "$gte": [ - "$$this.confidence", - 0.5, - ] - }, - {"$lte": ["$$this.confidence", 1]}, - ], - }, - {"$in": ["$$this.confidence", []]}, - ], - }, - }, - }, - }, - }, - { - "$addFields": { - "predictions.detections": { - "$filter": { - "input": "$predictions.detections", - "cond": {"$in": ["$$this.label", ["carrot"]]}, - }, - }, - }, - }, - ] - - self.assertEqual(expected, returned) + filters = { + "_label_tags": { + "values": ["two"], + "exclude": False, + "isMatching": True, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) + self.assertEqual(len(view.first().predictions.detections), 2) - @drop_datasets - def test_extended_view_video_label_filters_samples(self): filters = { - "frames.detections.detections.index": { - "range": [27, 54], - "_CLS": "numeric", + "_label_tags": { + "values": ["one"], "exclude": False, - "onlyMatch": True, "isMatching": False, }, - "frames.detections.detections.label": { - "values": ["vehicle"], - "exclude": False, - "onlyMatch": True, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) + self.assertEqual(len(view.first().predictions.detections), 1) + + view = fosv.get_view("test", pagination_data=True, filters=filters) + (sample,) = list( + foo.aggregate( + foo.get_db_conn()[view._dataset._sample_collection_name], + view._pipeline(), + ) + ) + self.assertIn("_label_tags", sample) + self.assertDictEqual(sample["_label_tags"], {"one": 1, "two": 1}) + + filters = { + "_label_tags": { + "values": ["two"], + "exclude": True, "isMatching": False, - "_CLS": "str", }, } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) + self.assertEqual(len(view.first().predictions.detections), 0) - dataset = fod.Dataset("test") - dataset.media_type = "video" - dataset.add_frame_field( - "detections", fof.EmbeddedDocumentField, fol.Detections + filters = { + "_label_tags": { + "values": ["one"], + "exclude": True, + "isMatching": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) + self.assertEqual( + view.first().predictions.detections[0].label, "not_carrot" ) - returned = fosv.get_view( - "test", - filters=filters, - count_label_tags=True, - )._pipeline()[1:] - - expected = [ - { - "$match": { - "$and": [ - { - "$and": [ - { - "frames.detections.detections.index": { - "$gte": 27 - } - }, - { - "frames.detections.detections.index": { - "$lte": 54 - } - }, - ], - }, - { - "frames.detections.detections.label": { - "$in": ["vehicle"] - } - }, - ], - }, - }, - {"$addFields": {"_label_tags": []}}, - { - "$addFields": { - "_label_tags": { - "$concatArrays": [ - "$_label_tags", - { - "$reduce": { - "input": "$frames", - "initialValue": [], - "in": { - "$concatArrays": [ - "$$value", - { - "$cond": { - "if": { - "$gt": [ - "$$this.detections.detections", - None, - ], - }, - "then": { - "$reduce": { - "input": "$$this.detections.detections", - "initialValue": [], - "in": { - "$concatArrays": [ - "$$value", - "$$this.tags", - ], - }, - }, - }, - "else": [], - }, - }, - ], - }, - }, - }, - ], - }, - }, - }, - { - "$addFields": { - "_label_tags": { - "$function": { - "body": "function(items) {let counts = {};items && items.forEach((i) => {counts[i] = 1 + (counts[i] || 0);});return counts;}", - "args": ["$_label_tags"], - "lang": "js", - }, - }, - }, - }, - ] - - self.assertEqual(expected, returned) + view = fosv.get_view("test", pagination_data=True, filters=filters) + (sample,) = list( + foo.aggregate( + foo.get_db_conn()[view._dataset._sample_collection_name], + view._pipeline(), + ) + ) + self.assertIn("_label_tags", sample) + self.assertDictEqual(sample["_label_tags"], {"two": 1}) + + filters = { + "_label_tags": { + "values": ["one"], + "exclude": True, + "isMatching": True, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 0) @drop_datasets - def test_extended_view_video_label_filters_aggregations(self): + def test_extended_frame_sample(self): + dataset = fod.Dataset("test") + sample = fos.Sample( + filepath="video.mp4", + ) + sample.frames[1] = fo.Frame( + predictions=fol.Detections( + detections=[ + fol.Detection( + label="carrot", confidence=0.25, tags=["one", "two"] + ), + fol.Detection( + label="not_carrot", confidence=0.75, tags=["two"] + ), + ] + ) + ) + dataset.add_sample(sample) + filters = { - "frames.detections.detections.index": { - "range": [27, 54], - "_CLS": "numeric", + "frames.predictions.detections.label": { + "values": ["carrot"], "exclude": False, - "onlyMatch": True, "isMatching": False, }, - "frames.detections.detections.label": { - "values": ["vehicle"], + "frames.predictions.detections.confidence": { + "range": [0.5, 1], "exclude": False, - "onlyMatch": True, "isMatching": False, - "_CLS": "str", }, } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 0) - dataset = fod.Dataset("test") - dataset.media_type = "video" - dataset.add_frame_field( - "detections", fof.EmbeddedDocumentField, fol.Detections + filters = { + "frames.predictions.detections.label": { + "values": ["carrot"], + "exclude": False, + "isMatching": False, + }, + "frames.predictions.detections.confidence": { + "range": [0.0, 0.5], + "exclude": False, + "isMatching": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) + self.assertEqual(len(view.first().frames[1].predictions.detections), 1) + + view = fosv.get_view("test", pagination_data=True) + (sample,) = list( + foo.aggregate( + foo.get_db_conn()[view._dataset._sample_collection_name], + view._pipeline(), + ) ) + self.assertIn("_label_tags", sample) + self.assertDictEqual(sample["_label_tags"], {"one": 1, "two": 2}) - returned = fosv.get_view( - "test", filters=filters, count_label_tags=False - )._pipeline()[1:] - - expected = [ - { - "$match": { - "$and": [ - { - "$and": [ - { - "frames.detections.detections.index": { - "$gte": 27 - } - }, - { - "frames.detections.detections.index": { - "$lte": 54 - } - }, - ], - }, - { - "frames.detections.detections.label": { - "$in": ["vehicle"] - } - }, - ], - }, - }, - { - "$addFields": { - "frames": { - "$map": { - "input": "$frames", - "as": "frame", - "in": { - "$mergeObjects": [ - "$$frame", - { - "detections": { - "$mergeObjects": [ - "$$frame.detections", - { - "detections": { - "$filter": { - "input": "$$frame.detections.detections", - "cond": { - "$or": [ - { - "$and": [ - { - "$gte": [ - "$$this.index", - 27, - ], - }, - { - "$lte": [ - "$$this.index", - 54, - ], - }, - ], - }, - { - "$in": [ - "$$this.index", - [], - ], - }, - ], - }, - }, - }, - }, - ], - }, - }, - ], - }, - }, - }, - }, - }, - { - "$addFields": { - "frames": { - "$map": { - "input": "$frames", - "as": "frame", - "in": { - "$mergeObjects": [ - "$$frame", - { - "detections": { - "$mergeObjects": [ - "$$frame.detections", - { - "detections": { - "$filter": { - "input": "$$frame.detections.detections", - "cond": { - "$in": [ - "$$this.label", - [ - "vehicle" - ], - ], - }, - }, - }, - }, - ], - }, - }, - ], - }, - }, - }, - }, - }, - ] - - self.assertEqual(expected, returned) + filters = { + "_label_tags": { + "values": ["two"], + "exclude": False, + "isMatching": True, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) + self.assertEqual(len(view.first().frames[1].predictions.detections), 2) - @drop_datasets - def test_extended_view_video_match_label_tags_aggregations(self): filters = { "_label_tags": { "values": ["one"], "exclude": False, + "isMatching": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) + self.assertEqual(len(view.first().frames[1].predictions.detections), 1) + + view = fosv.get_view("test", pagination_data=True, filters=filters) + (sample,) = list( + foo.aggregate( + foo.get_db_conn()[view._dataset._sample_collection_name], + view._pipeline(), + ) + ) + self.assertIn("_label_tags", sample) + self.assertDictEqual(sample["_label_tags"], {"one": 1, "two": 1}) + + filters = { + "_label_tags": { + "values": ["two"], + "exclude": True, + "isMatching": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) + self.assertEqual(len(view.first().frames[1].predictions.detections), 0) + + filters = { + "_label_tags": { + "values": ["one"], + "exclude": True, + "isMatching": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) + self.assertEqual( + view.first().frames[1].predictions.detections[0].label, + "not_carrot", + ) + + view = fosv.get_view("test", pagination_data=True, filters=filters) + (sample,) = list( + foo.aggregate( + foo.get_db_conn()[view._dataset._sample_collection_name], + view._pipeline(), + ) + ) + self.assertIn("_label_tags", sample) + self.assertDictEqual(sample["_label_tags"], {"two": 1}) + + filters = { + "_label_tags": { + "values": ["one"], + "exclude": True, "isMatching": True, - } + }, } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 0) + @drop_datasets + def test_extended_dynamic_image_sample(self): dataset = fod.Dataset("test") - dataset.media_type = "video" - dataset.add_frame_field( - "detections", fof.EmbeddedDocumentField, fol.Detections + sample = fos.Sample( + filepath="image.png", + dynamic=fo.DynamicEmbeddedDocument( + predictions=fol.Detections( + detections=[ + fol.Detection( + label="carrot", + confidence=0.25, + tags=["one", "two"], + ), + fol.Detection( + label="not_carrot", confidence=0.75, tags=["two"] + ), + ] + ), + bool=True, + int=1, + str="str", + list_bool=[True], + list_int=[1, 2], + list_str=["one", "two"], + ), + dynamic_list=[ + fo.DynamicEmbeddedDocument( + bool=True, + int=1, + str="str", + list_bool=[True], + list_int=[1, 2], + list_str=["one", "two"], + ) + ], ) + dataset.add_sample(sample) + dataset.add_dynamic_sample_fields() - returned = fosv.get_view( - "test", filters=filters, count_label_tags=True - )._pipeline()[1:] - - expected = [ - { - "$match": { - "$or": [ - {"frames.detections.detections.tags": {"$in": ["one"]}} - ], - }, - }, - {"$addFields": {"_label_tags": []}}, - { - "$addFields": { - "_label_tags": { - "$concatArrays": [ - "$_label_tags", - { - "$reduce": { - "input": "$frames", - "initialValue": [], - "in": { - "$concatArrays": [ - "$$value", - { - "$cond": { - "if": { - "$gt": [ - "$$this.detections.detections", - None, - ], - }, - "then": { - "$reduce": { - "input": "$$this.detections.detections", - "initialValue": [], - "in": { - "$concatArrays": [ - "$$value", - "$$this.tags", - ], - }, - }, - }, - "else": [], - }, - }, - ], - }, - }, - }, - ], - }, - }, - }, - { - "$addFields": { - "_label_tags": { - "$function": { - "body": "function(items) {let counts = {};items && items.forEach((i) => {counts[i] = 1 + (counts[i] || 0);});return counts;}", - "args": ["$_label_tags"], - "lang": "js", - }, - }, - }, - }, - ] - - self.assertEqual(expected, returned) + filters = { + "dynamic.predictions.detections.label": { + "values": ["carrot"], + "exclude": False, + "isMatching": False, + }, + "dynamic.predictions.detections.confidence": { + "range": [0.5, 1], + "exclude": False, + "isMatching": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 0) + + filters = { + "dynamic.predictions.detections.label": { + "values": ["carrot"], + "exclude": False, + "isMatching": False, + }, + "dynamic.predictions.detections.confidence": { + "range": [0.0, 0.5], + "exclude": False, + "isMatching": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) + self.assertEqual(len(view.first().dynamic.predictions.detections), 1) + + filters = { + "dynamic.list_str": { + "values": ["one"], + "exclude": False, + }, + "dynamic.list_int": { + "range": [0, 2], + "exclude": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) + self.assertEqual(len(view.first().dynamic.list_str), 2) + self.assertEqual(len(view.first().dynamic.list_int), 2) + + filters = { + "dynamic.list_str": { + "values": ["empty"], + "exclude": False, + }, + "dynamic.list_int": { + "range": [0, 2], + "exclude": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 0) + + filters = { + "dynamic.list_str": { + "values": ["one"], + "exclude": False, + }, + "dynamic.list_int": { + "range": [3, 4], + "exclude": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 0) + + filters = { + "dynamic.list_bool": { + "true": False, + "false": True, + "exclude": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 0) + + filters = { + "dynamic.list_bool": { + "true": False, + "false": True, + "exclude": True, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) + + filters = { + "dynamic.list_bool": { + "true": True, + "false": False, + "exclude": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) + + view = fosv.get_view("test", pagination_data=True) + (sample,) = list( + foo.aggregate( + foo.get_db_conn()[view._dataset._sample_collection_name], + view._pipeline(), + ) + ) + self.assertIn("_label_tags", sample) + self.assertDictEqual(sample["_label_tags"], {"one": 1, "two": 2}) + + filters = { + "_label_tags": { + "values": ["two"], + "exclude": False, + "isMatching": True, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) + self.assertEqual(len(view.first().dynamic.predictions.detections), 2) - @drop_datasets - def test_extended_view_video_match_label_tags_samples(self): filters = { "_label_tags": { "values": ["one"], "exclude": False, "isMatching": False, - } + }, } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) + self.assertEqual(len(view.first().dynamic.predictions.detections), 1) + + view = fosv.get_view("test", pagination_data=True, filters=filters) + (sample,) = list( + foo.aggregate( + foo.get_db_conn()[view._dataset._sample_collection_name], + view._pipeline(), + ) + ) + self.assertIn("_label_tags", sample) + self.assertDictEqual(sample["_label_tags"], {"one": 1, "two": 1}) + filters = { + "_label_tags": { + "values": ["two"], + "exclude": True, + "isMatching": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) + self.assertEqual(len(view.first().dynamic.predictions.detections), 0) + + filters = { + "_label_tags": { + "values": ["one"], + "exclude": True, + "isMatching": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) + self.assertEqual( + view.first().dynamic.predictions.detections[0].label, "not_carrot" + ) + + view = fosv.get_view("test", pagination_data=True, filters=filters) + (sample,) = list( + foo.aggregate( + foo.get_db_conn()[view._dataset._sample_collection_name], + view._pipeline(), + ) + ) + self.assertIn("_label_tags", sample) + self.assertDictEqual(sample["_label_tags"], {"two": 1}) + + filters = { + "_label_tags": { + "values": ["one"], + "exclude": True, + "isMatching": True, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 0) + + filters = { + "dynamic_list.bool": { + "true": False, + "false": True, + "exclude": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 0) + + filters = { + "dynamic_list.list_bool": { + "true": False, + "false": True, + "exclude": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 0) + + filters = { + "dynamic_list.bool": { + "true": True, + "false": False, + "exclude": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) + + filters = { + "dynamic_list.list_bool": { + "true": True, + "false": False, + "exclude": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) + + filters = { + "dynamic_list.int": { + "range": [-1, 0], + "exclude": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 0) + + filters = { + "dynamic_list.list_int": { + "range": [-1, 0], + "exclude": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 0) + + filters = { + "dynamic_list.int": { + "range": [0, 1], + "exclude": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) + + filters = { + "dynamic_list.list_int": { + "range": [0, 1], + "exclude": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) + + filters = { + "dynamic_list.int": { + "range": [0, 2], + "exclude": True, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 0) + + filters = { + "dynamic_list.list_int": { + "range": [0, 2], + "exclude": True, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 0) + + @drop_datasets + def test_extended_keypoint_sample(self): dataset = fod.Dataset("test") - dataset.media_type = "video" - dataset.add_frame_field( - "detections", fof.EmbeddedDocumentField, fol.Detections + dataset.default_skeleton = fo.KeypointSkeleton( + labels=["top-left", "center", "bottom-right"], edges=[[0, 1, 2]] ) + sample = fos.Sample( + filepath="video.mp4", + keypoint=fo.Keypoint( + label="keypoint", + points=[[0, 0], [0.5, 0.5], [1, 1]], + confidence=[0, 0.5, 1], + dynamic=["one", "two", "three"], + tags=["keypoint"], + ), + keypoints=fo.Keypoints( + keypoints=[ + fo.Keypoint( + label="keypoint", + points=[[0, 0], [0.5, 0.5], [1, 1]], + confidence=[0, 0.5, 1], + dynamic=["one", "two", "three"], + tags=["keypoint"], + ) + ] + ), + ) + + dataset.add_sample(sample) + dataset.add_dynamic_sample_fields() + dataset.add_dynamic_frame_fields() - returned = fosv.get_view( - "test", filters=filters, count_label_tags=False - )._pipeline()[1:] - - expected = [ - { - "$match": { - "$or": [ - {"frames.detections.detections.tags": {"$in": ["one"]}} - ], - }, - }, - { - "$addFields": { - "frames": { - "$map": { - "input": "$frames", - "as": "frame", - "in": { - "$mergeObjects": [ - "$$frame", - { - "detections": { - "$mergeObjects": [ - "$$frame.detections", - { - "detections": { - "$filter": { - "input": "$$frame.detections.detections", - "cond": { - "$cond": { - "if": { - "$gt": [ - "$$this.tags", - None, - ], - }, - "then": { - "$in": [ - "one", - "$$this.tags", - ], - }, - "else": False, - }, - }, - }, - }, - }, - ], - }, - }, - ], - }, - }, - }, - }, - }, - { - "$match": { - "$expr": { - "$gt": [ - { - "$reduce": { - "input": "$frames", - "initialValue": 0, - "in": { - "$add": [ - "$$value", - { - "$size": { - "$ifNull": [ - "$$this.detections.detections", - [], - ], - }, - }, - ], - }, - }, - }, - 0, - ], - }, - }, - }, - ] - - self.assertEqual(expected, returned) + filters = { + "keypoint.label": { + "values": ["empty"], + "exclude": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 0) + + filters = { + "keypoint.label": { + "values": ["keypoint"], + "exclude": True, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 0) + + filters = { + "keypoint.points": { + "values": ["top-left"], + "exclude": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) + self.assertListEqual(view.first().keypoint.points[0], [0, 0]) + for point in view.first().keypoint.points[1:]: + self.assertTrue(math.isnan(point[0])) + self.assertTrue(math.isnan(point[1])) + + filters = { + "keypoint.points": { + "values": ["top-left"], + "exclude": False, + }, + } + view = fosv.get_view("test", filters=filters) + self.assertEqual(len(view), 1) diff --git a/tests/unittests/view_tests.py b/tests/unittests/view_tests.py index 441ba2ab85..603817d48b 100644 --- a/tests/unittests/view_tests.py +++ b/tests/unittests/view_tests.py @@ -2738,6 +2738,152 @@ def test_filter_keypoints(self): view = dataset.filter_keypoints("kps", labels=[]) self.assertEqual(len(view), 0) + def test_filter_keypoints_embedded_document(self): + sample1 = fo.Sample( + filepath="image1.jpg", + dynamic=fo.DynamicEmbeddedDocument( + kp=fo.Keypoint( + label="person", + points=[(0, 0), (0, 0), (0, 0), (0, 0), (0, 0)], + confidence=[0.5, 0.6, 0.7, 0.8, 0.9], + ), + kps=fo.Keypoints( + keypoints=[ + fo.Keypoint( + label="person", + points=[(0, 0), (0, 0), (0, 0), (0, 0), (0, 0)], + confidence=[0.5, 0.6, 0.7, 0.8, 0.9], + ), + fo.Keypoint(), + ] + ), + ), + ) + + sample2 = fo.Sample(filepath="image2.jpg") + + dataset = fo.Dataset() + dataset.add_samples([sample1, sample2], dynamic=True) + + dataset.default_skeleton = fo.KeypointSkeleton( + labels=["nose", "left eye", "right eye", "left ear", "right ear"], + edges=[[0, 1, 2, 0], [0, 3], [0, 4]], + ) + + count_nans = lambda points: len([p for p in points if np.isnan(p[0])]) + + # + # Test `Keypoint` sample fields + # + + # only_matches=True + view = dataset.filter_keypoints( + "dynamic.kp", filter=F("confidence") > 0.75 + ) + self.assertEqual(len(view), 1) + sample = view.first() + self.assertEqual(len(sample["dynamic.kp"].points), 5) + self.assertEqual(count_nans(sample["dynamic.kp"].points), 3) + + # only_matches=False + view = dataset.filter_keypoints( + "dynamic.kp", filter=F("confidence") > 0.75, only_matches=False + ) + self.assertEqual(len(view), 2) + sample = view.first() + self.assertEqual(len(sample["dynamic.kp"].points), 5) + self.assertEqual(count_nans(sample["dynamic.kp"].points), 3) + + # view with no matches + view = dataset.filter_keypoints( + "dynamic.kp", filter=F("confidence") > 0.95 + ) + self.assertEqual(len(view), 0) + + # only_matches=True + view = dataset.filter_keypoints( + "dynamic.kp", labels=["left eye", "right eye"] + ) + self.assertEqual(len(view), 1) + sample = view.first() + self.assertEqual(len(sample["dynamic.kp"].points), 5) + self.assertEqual(count_nans(sample["dynamic.kp"].points), 3) + + # only_matches=False + view = dataset.filter_keypoints( + "dynamic.kp", labels=["left eye", "right eye"], only_matches=False + ) + self.assertEqual(len(view), 2) + sample = view.first() + self.assertEqual(len(sample["dynamic.kp"].points), 5) + self.assertEqual(count_nans(sample["dynamic.kp"].points), 3) + + # view with no matches + view = dataset.filter_keypoints("dynamic.kp", labels=[]) + self.assertEqual(len(view), 0) + + # + # Test `Keypoints` sample fields + # + + # only_matches=True + view = dataset.filter_keypoints( + "dynamic.kps", filter=F("confidence") > 0.75 + ) + self.assertEqual(len(view), 1) + self.assertEqual(view.count("dynamic.kps.keypoints"), 1) + sample = view.first() + self.assertEqual(len(sample["dynamic.kps"].keypoints[0].points), 5) + self.assertEqual( + count_nans(sample["dynamic.kps"].keypoints[0].points), 3 + ) + + # only_matches=False + view = dataset.filter_keypoints( + "dynamic.kps", filter=F("confidence") > 0.75, only_matches=False + ) + self.assertEqual(len(view), 2) + self.assertEqual(view.count("dynamic.kps.keypoints"), 2) + sample = view.first() + self.assertEqual(len(sample["dynamic.kps"].keypoints[0].points), 5) + self.assertEqual( + count_nans(sample["dynamic.kps"].keypoints[0].points), 3 + ) + + # view with no matches + view = dataset.filter_keypoints( + "dynamic.kps", filter=F("confidence") > 0.95 + ) + self.assertEqual(len(view), 0) + + # only_matches=True + view = dataset.filter_keypoints( + "dynamic.kps", labels=["left eye", "right eye"] + ) + self.assertEqual(len(view), 1) + self.assertEqual(view.count("dynamic.kps.keypoints"), 1) + sample = view.first() + self.assertEqual(len(sample["dynamic.kps"].keypoints[0].points), 5) + self.assertEqual( + count_nans(sample["dynamic.kps"].keypoints[0].points), 3 + ) + + # only_matches=False + view = dataset.filter_keypoints( + "dynamic.kps", labels=["left eye", "right eye"], only_matches=False + ) + self.assertEqual(len(view), 2) + self.assertEqual(view.count("dynamic.kps.keypoints"), 2) + sample = view.first() + self.assertEqual(len(sample["dynamic.kps"].keypoints[0].points), 5) + self.assertEqual( + count_nans(sample["dynamic.kps"].keypoints[0].points), 3 + ) + + # view with no matches + view = dataset.filter_keypoints("dynamic.kps", labels=[]) + self.assertEqual(len(view), 0) + def test_filter_keypoints_frames(self): sample1 = fo.Sample(filepath="video1.mp4") sample1.frames[1] = fo.Frame(