Repository: voxel51/fiftyone

Issue:
[FR] Add support for negative matches via filter widgets
The App's filter widgets do not currently provide a way to do "negative matches".

For example with this dataset:

```py
import fiftyone as fo
import fiftyone.zoo as foz

dataset = foz.load_zoo_dataset("quickstart")
session = fo.launch_app(dataset)
```

and this filter state:

<img width="288" alt="Screen Shot 2022-12-06 at 12 11 43 PM" src="https://user-images.githubusercontent.com/25985824/205978550-3a379a31-ea91-4933-8b3a-c1eacf6e93c3.png">

what is happening is:
- Remove all person boxes, then omit all samples that have no boxes after filtering

which results in 199 of the 200 samples in the grid, many of which do actually contain people, it's just that the boxes have been removed.

This is *not* the same thing as the negative matching condition: 
- Only show samples that do not contain person boxes

which is not currently possible with the filtering UI we have created.

Other examples of negative matching that is not currently supported:
- Show me samples that *do not* have the given sample tag
- Show me samples whose `ListField` contains no values

One possible approach here is to add a `Show matches|Show non-matches` toggle below the `Select|Exclude` toggle. But perhaps the naming/UI can be improved.


Discussion:
The lack of negative matching gave rise to https://github.com/voxel51/fiftyone/issues/2277, for example, where we tried to add a `None` value to the list, but that just ended up being confusing because `None` is not the same as an actually present value. They are different concepts. 
See also:
- https://github.com/voxel51/fiftyone/issues/2277
- https://github.com/voxel51/fiftyone/issues/2404
@lanzhenw documenting our offline design discussion:

Here are all of the filtering operations that a user might want to perform:

```py
import fiftyone as fo
import fiftyone.zoo as foz
from fiftyone import ViewField as F

dataset = foz.load_zoo_dataset("quickstart")

labels = ["cat", "dog"]

# Select ['cat', 'dog'] labels and omit empty samples
view1 = dataset.filter_labels("ground_truth", F("label").is_in(labels))
assert sorted(view1.distinct("ground_truth.detections.label")) == labels
assert view1.bounds(F("ground_truth.detections").length())[0] > 0

# Select ['cat', 'dog'] labels
view2 = dataset.filter_labels("ground_truth", F("label").is_in(labels), only_matches=False)
assert sorted(view2.distinct("ground_truth.detections.label")) == labels
assert view2.bounds(F("ground_truth.detections").length())[0] == 0

# Exclude ['cat', 'dog'] labels and omit empty samples
view3 = dataset.filter_labels("ground_truth", ~F("label").is_in(labels))
assert "cat" not in view3.distinct("ground_truth.detections.label")
assert view3.bounds(F("ground_truth.detections").length())[0] > 0

# Exclude ['cat', 'dog'] labels
view4 = dataset.filter_labels("ground_truth", ~F("label").is_in(labels), only_matches=False)
assert "cat" not in view4.distinct("ground_truth.detections.label")
assert view4.bounds(F("ground_truth.detections").length())[0] == 0

# Show samples containing ['cat', 'dog'] labels
view5 = dataset.match_labels(fields="ground_truth", filter=F("label").is_in(labels))
assert len(view1) == len(view5)
assert view5.count("ground_truth.detections") == dataset.select(view5).count("ground_truth.detections")

# Show samples that do not contain ['cat', 'dog'] labels
view6 = dataset.match_labels(fields="ground_truth", filter=F("label").is_in(labels), bool=False)
assert "cat" not in view6.distinct("ground_truth.detections.label")
assert view6.count("ground_truth.detections") == dataset.select(view6).count("ground_truth.detections")
```

In order to expose this in the UI, our idea is to modify the existing UI below in the following ways:

<img width="293" alt="Screen Shot 2022-12-14 at 10 41 02 PM" src="https://user-images.githubusercontent.com/25985824/207767222-e780535a-7ae3-449b-ba1d-259f68134f2f.png">

1. Replace the `Select | Exclude` widget with a simple icon + text that describes in plain text the current filter that is applied. For example: 

```
[i] Showing ['cat', 'dog'] labels and omitting empty samples
```

When the user clicks on the `[i]` icon, they would then be presented with a dropdown of icons + text describing the different filtering operations available to them:

```
[i] Select ['cat', 'dog'] labels and omit empty samples
[i] Select ['cat', 'dog'] labels
[i] Exclude ['cat', 'dog'] labels and omit empty samples
[i] Exclude ['cat', 'dog'] labels
[i] Show samples containing ['cat', 'dog'] labels
[i] Show samples that do not contain ['cat', 'dog'] labels
```

Ideally we can find different icons for each option that convey what each operation does in a succinct way so that the text doesn't have to be read fully everytime.

Resolving patch:
diff --git a/fiftyone/server/samples.py b/fiftyone/server/samples.py
--- a/fiftyone/server/samples.py
+++ b/fiftyone/server/samples.py
@@ -80,7 +80,6 @@ async def paginate_samples(
         count_label_tags=True,
         extended_stages=extended_stages,
         sort=True,
-        only_matches=True,
         sample_filter=sample_filter,
     )
 
diff --git a/fiftyone/server/view.py b/fiftyone/server/view.py
--- a/fiftyone/server/view.py
+++ b/fiftyone/server/view.py
@@ -208,7 +208,6 @@ def get_view(
     stages=None,
     filters=None,
     count_label_tags=False,
-    only_matches=True,
     extended_stages=None,
     sample_filter=None,
     sort=False,
@@ -261,7 +260,6 @@ def get_view(
             view,
             filters,
             count_label_tags=count_label_tags,
-            only_matches=only_matches,
             extended_stages=extended_stages,
             sort=sort,
         )
@@ -273,7 +271,6 @@ def get_extended_view(
     view,
     filters=None,
     count_label_tags=False,
-    only_matches=True,
     extended_stages=None,
     sort=False,
 ):
@@ -284,10 +281,10 @@ def get_extended_view(
         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
-        only_matches (True): whether to filter unmatches samples when filtering
-            labels
         extended_stages (None): extended view stages
         sort (False): wheter to include sort extended stages
+
+    The function returns a fiftyone.core.collections.SampleCollection
     """
     cleanup_fields = set()
     filtered_labels = set()
@@ -310,7 +307,6 @@ def get_extended_view(
             filters,
             label_tags=label_tags,
             hide_result=count_label_tags,
-            only_matches=only_matches,
         )
 
         for stage in stages:
@@ -328,6 +324,15 @@ def get_extended_view(
 
 
 def extend_view(view, extended_stages, sort):
+
+    """If sorting conditions are met, it will the sorting of the sample based on the extended stages defined.
+    Then add the stages to the view and return this modified view.
+    Args:
+        view: a :class:`fiftyone.core.collections.SampleCollection`
+        extended_stages: extended view stages
+        sort (False): wheter to include sort extended stages
+    """
+
     for cls, d in extended_stages.items():
         kwargs = [[k, v] for k, v in d.items()]
         stage = fosg.ViewStage._from_dict({"_cls": cls, "kwargs": kwargs})
@@ -338,6 +343,13 @@ def extend_view(view, extended_stages, sort):
 
 
 def _add_labels_tags_counts(view, filtered_fields, label_tags):
+
+    """This counts the number of items in the `_LABEL_TAGS` field and returns the modified view.
+    Args:
+        view: a :class:`fiftyone.core.collections.SampleCollection`
+        filtered_fields: filtered fields
+        label_tags: label tags
+    """
     view = view.set_field(_LABEL_TAGS, [], _allow_missing=True)
 
     for path, field in iter_label_fields(view):
@@ -366,6 +378,14 @@ def _add_labels_tags_counts(view, filtered_fields, label_tags):
 
 
 def _make_expression(field, path, args):
+
+    """This returns a MongoDB expression that can be used to filter a view based on the field, path and provided filter(args)
+    Args:
+        field: a :class:`fiftyone.core.fields.Field`
+        path: a :class:`str` representing the path to the field
+        args: a :class:`dict` representing the filter arguments, usually with keys `values`, `exclude``
+    """
+
     if not path:
         return _make_scalar_expression(F(), args, field)
 
@@ -376,12 +396,22 @@ def _make_expression(field, path, args):
         field, fof.FrameSupportField
     ):
         new_field = field.field
-        expr = (
-            lambda subexpr: F(field.db_field or field.name)
-            .filter(subexpr)
-            .length()
-            > 0
-        )
+        if args["exclude"]:
+            # in ListField, exclude uses match(expr).length() == 0 instead of match(~expr), therefore, exclude needs to be set to False here to get the correct subexpr
+            args["exclude"] = False
+            expr = (
+                lambda subexpr: F(field.db_field or field.name)
+                .filter(subexpr)
+                .length()
+                == 0
+            )
+        else:
+            expr = (
+                lambda subexpr: F(field.db_field or field.name)
+                .filter(subexpr)
+                .length()
+                > 0
+            )
     else:
         new_field = field
         expr = lambda subexpr: F(field.db_field or field.name).apply(subexpr)
@@ -394,8 +424,12 @@ def _make_expression(field, path, args):
 
 
 def _make_filter_stages(
-    view, filters, label_tags=None, hide_result=False, only_matches=True
+    view,
+    filters,
+    label_tags=None,
+    hide_result=False,
 ):
+    """This creates filter stages using the provided filters and label tags."""
     field_schema = view.get_field_schema()
     if view.media_type != fom.IMAGE:
         frame_field_schema = view.get_frame_field_schema()
@@ -406,12 +440,16 @@ def _make_filter_stages(
         F("tags").contains(label_tags), None
     )
     cache = {}
-
     stages = []
     cleanup = set()
     filtered_labels = set()
     for path in sorted(filters):
         args = filters[path]
+
+        # use the setting within the filter, otherwise use default true settings
+        is_matching = True if "isMatching" not in args else args["isMatching"]
+        only_match = True if "onlyMatch" not in args else args["onlyMatch"]
+
         if path == "tags" or path.startswith("_"):
             continue
 
@@ -446,8 +484,11 @@ def _make_filter_stages(
                     path if path.endswith(".points") else None,
                 )
                 if keypoints
-                else _make_scalar_expression(view_field, args, field)
+                else _make_scalar_expression(
+                    view_field, args, field, is_label=True
+                )
             )
+
             if expr is not None:
                 if hide_result:
                     new_field = "__%s" % path.split(".")[1 if frames else 0]
@@ -465,16 +506,26 @@ def _make_filter_stages(
                         _new_field=new_field,
                         **expr,
                     )
+                elif is_matching:
+                    field = (
+                        cache.get(prefix + parent.name, prefix + parent.name),
+                    )
+                    stage = fosg.MatchLabels(
+                        fields=field[0],
+                        filter=expr,
+                        bool=(not args["exclude"]),
+                    )
                 else:
                     stage = fosg.FilterLabels(
                         cache.get(prefix + parent.name, prefix + parent.name),
                         expr,
-                        only_matches=only_matches,
+                        only_matches=only_match,
                         _new_field=new_field,
                     )
 
                 stages.append(stage)
                 filtered_labels.add(path)
+
                 if new_field:
                     cache[prefix + parent.name] = new_field
                     cleanup.add(new_field)
@@ -484,6 +535,7 @@ def _make_filter_stages(
                 stages.append(fosg.Match(expr))
 
     if label_tags is not None and hide_result:
+
         for path, _ in iter_label_fields(view):
             if hide_result:
                 new_field = _get_filtered_path(
@@ -542,15 +594,15 @@ def _is_label(field):
     )
 
 
-def _make_scalar_expression(f, args, field):
+def _make_scalar_expression(f, args, field, list_field=False, is_label=False):
     expr = None
+    is_matching = False if "isMatching" not in args else args["isMatching"]
     if isinstance(field, fof.ListField):
-        return (
-            f.filter(_make_scalar_expression(F(), args, field.field)).length()
-            > 0
-        )
-
-    if isinstance(field, fof.BooleanField):
+        expr = f.filter(
+            _make_scalar_expression(F(), args, field.field, list_field=True)
+        ).length()
+        return expr == 0 if args["exclude"] else expr > 0
+    elif isinstance(field, fof.BooleanField):
         true, false = args["true"], args["false"]
         if true and false:
             expr = f.is_in([True, False])
@@ -587,11 +639,12 @@ def _make_scalar_expression(f, args, field):
         expr = f.is_in(values)
         exclude = args["exclude"]
 
-        if exclude:
+        # list_field handles exclude separately
+        if exclude and not (is_label and is_matching) and not list_field:
             # pylint: disable=invalid-unary-operand-type
             expr = ~expr
 
-        if none:
+        if none and not (is_label and is_matching) and not list_field:
             if exclude:
                 expr &= f.exists()
             else:
@@ -599,9 +652,10 @@ def _make_scalar_expression(f, args, field):
 
         return expr
 
-    return _apply_others(expr, f, args)
+    return _apply_others(expr, f, args, is_label)
 
 
+# generate expr for keypoint labels
 def _make_keypoint_kwargs(args, view, points):
     if points:
         ske = view._dataset.default_skeleton
@@ -621,7 +675,8 @@ def _make_keypoint_kwargs(args, view, points):
     return {"filter": (f >= mn) & (f <= mx)}
 
 
-def _apply_others(expr, f, args):
+def _apply_others(expr, f, args, is_label):
+    is_matching = False if "isMatching" not in args else args["isMatching"]
     nonfinites = {
         "nan": float("nan"),
         "ninf": -float("inf"),
@@ -640,7 +695,11 @@ def _apply_others(expr, f, args):
     if "none" in args:
         expr = _apply_none(expr, f, args["none"])
 
-    if "exclude" in args and args["exclude"]:
+    if (
+        "exclude" in args
+        and args["exclude"]
+        and not (is_matching and is_label)
+    ):
         # pylint: disable=invalid-unary-operand-type
         expr = ~expr
 


Test patch:
diff --git a/app/packages/state/tests/recoil/filters.test.ts b/app/packages/state/tests/recoil/filters.test.ts
new file mode 100644
--- /dev/null
+++ b/app/packages/state/tests/recoil/filters.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("recoil");
+vi.mock("recoil-relay");
+
+import { setMockAtoms, TestSelectorFamily } from "../../../../__mocks__/recoil";
+
+import * as filters from "../../src/recoil/filters";
+import * as string from "../../src/recoil/pathFilters/string";
+
+describe("filter resolves correctly", () => {
+  const testModal = <TestSelectorFamily<typeof filters.filter>>(
+    (<unknown>filters.filter({ path: "test", modal: true }))
+  );
+
+  const testGrid = <TestSelectorFamily<typeof filters.filter>>(
+    (<unknown>filters.filter({ path: "test", modal: false }))
+  );
+
+  setMockAtoms({
+    filters: { test: "grid filters" },
+    modalFilters: { test: "modal filters" },
+  });
+
+  it("resolves filter correctly in grid view", () => {
+    expect(testGrid()).toBe("grid filters");
+  });
+
+  it("resolves filter correctly in modal view", () => {
+    expect(testModal()).toBe("modal filters");
+  });
+});
+
+describe("hasFilter resolves correctly", () => {
+  const test = <TestSelectorFamily<typeof filters.hasFilters>>(
+    (<unknown>filters.hasFilters(false))
+  );
+  it("hasFilter resolves correctly when there is filter", () => {
+    setMockAtoms({
+      filters: { test: "grid filters" },
+      modalFilters: { test: "modal filters" },
+    });
+    expect(test()).toBe(true);
+  });
+
+  it("hasFilter resolves correctly when there is hidden label ids, modal is open", () => {
+    setMockAtoms({
+      modalFilters: { test: "modal filters" },
+      hiddenLabelIds: ["1", "2"],
+    });
+    const test2 = <TestSelectorFamily<typeof filters.hasFilters>>(
+      (<unknown>filters.hasFilters(true))
+    );
+    expect(test2()).toBe(true);
+  });
+});
diff --git a/tests/unittests/server_tests.py b/tests/unittests/server_tests.py
--- a/tests/unittests/server_tests.py
+++ b/tests/unittests/server_tests.py
@@ -22,11 +22,16 @@ def test_extended_view_image_label_filters_samples(self):
             "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,
             },
         }
 
@@ -36,7 +41,9 @@ def test_extended_view_image_label_filters_samples(self):
         )
 
         returned = fosv.get_view(
-            "test", filters=filters, count_label_tags=True, only_matches=True
+            "test",
+            filters=filters,
+            count_label_tags=True,
         )._pipeline()
 
         expected = [
@@ -158,11 +165,16 @@ def test_extended_view_image_label_filters_aggregations(self):
             "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,
             },
         }
 
@@ -172,7 +184,7 @@ def test_extended_view_image_label_filters_aggregations(self):
         )
 
         returned = fosv.get_view(
-            "test", filters=filters, count_label_tags=False, only_matches=True
+            "test", filters=filters, count_label_tags=False
         )._pipeline()
 
         expected = [
@@ -254,10 +266,15 @@ def test_extended_view_video_label_filters_samples(self):
             "frames.detections.detections.index": {
                 "range": [27, 54],
                 "_CLS": "numeric",
+                "exclude": False,
+                "onlyMatch": True,
+                "isMatching": False,
             },
             "frames.detections.detections.label": {
                 "values": ["vehicle"],
                 "exclude": False,
+                "onlyMatch": True,
+                "isMatching": False,
                 "_CLS": "str",
             },
         }
@@ -269,7 +286,9 @@ def test_extended_view_video_label_filters_samples(self):
         )
 
         returned = fosv.get_view(
-            "test", filters=filters, count_label_tags=True, only_matches=True
+            "test",
+            filters=filters,
+            count_label_tags=True,
         )._pipeline()[1:]
 
         expected = [
@@ -476,10 +495,15 @@ def test_extended_view_video_label_filters_aggregations(self):
             "frames.detections.detections.index": {
                 "range": [27, 54],
                 "_CLS": "numeric",
+                "exclude": False,
+                "onlyMatch": True,
+                "isMatching": False,
             },
             "frames.detections.detections.label": {
                 "values": ["vehicle"],
                 "exclude": False,
+                "onlyMatch": True,
+                "isMatching": False,
                 "_CLS": "str",
             },
         }
@@ -491,7 +515,7 @@ def test_extended_view_video_label_filters_aggregations(self):
         )
 
         returned = fosv.get_view(
-            "test", filters=filters, count_label_tags=False, only_matches=True
+            "test", filters=filters, count_label_tags=False
         )._pipeline()[1:]
 
         expected = [
@@ -658,7 +682,7 @@ def test_extended_view_video_match_label_tags_aggregations(self):
         )
 
         returned = fosv.get_view(
-            "test", filters=filters, count_label_tags=True, only_matches=True
+            "test", filters=filters, count_label_tags=True
         )._pipeline()[1:]
 
         expected = [
@@ -796,7 +820,7 @@ def test_extended_view_video_match_label_tags_samples(self):
         )
 
         returned = fosv.get_view(
-            "test", filters=filters, count_label_tags=False, only_matches=True
+            "test", filters=filters, count_label_tags=False
         )._pipeline()[1:]
 
         expected = [