Repository: voxel51/fiftyone

Issue:
[FR] Tags update (combine sample tags and label tags into a single group)
### Proposal Summary

We should update the SAMPLE TAGS and LABEL TAGS sections in the App to instead show a single TAGS section that contains tags and label tags sections that use the existing ListField(StringField) filtering widgets to encapsulate the existing sample and label tags filtering UIs, respectively.

The benefits of this are numerous:

- Removes custom code that currently exists for sample/label tags sections
- Enables us to leverage the filter type UI in to provide more options like showing samples that do not contain a given tag

Notes

This will require migrating existing app_config.sidebar_groups to use the new tags group name





Discussion:

Resolving patch:
diff --git a/fiftyone/core/odm/dataset.py b/fiftyone/core/odm/dataset.py
--- a/fiftyone/core/odm/dataset.py
+++ b/fiftyone/core/odm/dataset.py
@@ -336,8 +336,7 @@ def _make_default_sidebar_groups(sample_collection):
         )
 
     sidebar_groups = [
-        SidebarGroupDocument(name="tags"),
-        SidebarGroupDocument(name="label tags"),
+        SidebarGroupDocument(name="tags", paths=["tags", "_label_tags"]),
         SidebarGroupDocument(name="metadata", paths=metadata),
         SidebarGroupDocument(name="labels", paths=labels),
     ]
diff --git a/fiftyone/migrations/revisions/v0_20_0.py b/fiftyone/migrations/revisions/v0_20_0.py
--- a/fiftyone/migrations/revisions/v0_20_0.py
+++ b/fiftyone/migrations/revisions/v0_20_0.py
@@ -53,9 +53,52 @@ def up(db, dataset_name):
         coll_name = dataset_dict.get("frame_collection_name", None)
         if coll_name is not None:
             db[coll_name].update_many({}, {"$set": {"_dataset_id": _id}})
+    
+    app_config = dataset_dict.get("app_config", None)
+    if app_config is not None:
+        sidebar_groups = app_config.get("sidebar_groups", None)
+        if sidebar_groups is not None:
+            label_tags_idx = None
 
-    db.datasets.replace_one(match_d, dataset_dict)
+            for idx, sidebar_group in enumerate(sidebar_groups):
+                name = sidebar_group.get("name", None)
+                if name == "tags":
+                    sidebar_group["paths"] = ["tags", "_label_tags"]
+
+                if name == "label tags":
+                    label_tags_idx = idx
 
+            if label_tags_idx is not None:
+                sidebar_groups.pop(label_tags_idx)
+
+    db.datasets.replace_one(match_d, dataset_dict)
 
 def down(db, dataset_name):
-    pass
+    match_d = {"name": dataset_name}
+    dataset_dict = db.datasets.find_one(match_d)
+
+    app_config = dataset_dict.get("app_config", None)
+    if app_config is not None:
+        sidebar_groups = app_config.get("sidebar_groups", None)
+        if sidebar_groups is not None:
+            tags_idx = None
+            found_label_tags = False
+
+            for idx, sidebar_group in enumerate(sidebar_groups):
+                name = sidebar_group.get("name", None)
+
+                if name == "tags":
+                    sidebar_group["paths"] = []
+                    tags_idx = idx
+
+                if name == "label tags":
+                    sidebar_group["paths"] = []
+                    found_label_tags = True
+
+            if tags_idx is not None and not found_label_tags:
+                sidebar_groups.insert(
+                    tags_idx + 1,
+                    {"name": "label tags", "paths": []},
+                )
+
+    db.datasets.replace_one(match_d, dataset_dict)
diff --git a/fiftyone/server/view.py b/fiftyone/server/view.py
--- a/fiftyone/server/view.py
+++ b/fiftyone/server/view.py
@@ -22,6 +22,7 @@
 from fiftyone.server.aggregations import GroupElementFilter, SampleFilter
 from fiftyone.server.scalars import BSONArray, JSON
 import fiftyone.server.utils as fosu
+from fiftyone.core.utils import pprint
 
 
 _LABEL_TAGS = "_label_tags"
@@ -159,14 +160,51 @@ def get_extended_view(
     if filters:
         if "tags" in filters:
             tags = filters.get("tags")
-            if "label" in tags:
-                label_tags = tags["label"]
-
-            if not count_label_tags and label_tags:
-                view = view.select_labels(tags=label_tags)
+            exclude = tags["exclude"]
+            tags = tags["values"]
+
+            if tags and not exclude:
+                view = view.match_tags(tags)
+
+            if tags and exclude:
+                view = view.match_tags(tags, bool=False)
+
+        if "_label_tags" in filters:
+            label_tags = filters.get("_label_tags")
+
+            if (
+                not count_label_tags
+                and label_tags
+                and not label_tags["exclude"]
+                and not label_tags["isMatching"]
+            ):
+                view = view.select_labels(tags=label_tags["values"])
+
+            if (
+                not count_label_tags
+                and label_tags
+                and label_tags["exclude"]
+                and not label_tags["isMatching"]
+            ):
+                view = view.exclude_labels(
+                    tags=label_tags["values"], omit_empty=False
+                )
 
-            if "sample" in tags:
-                view = view.match_tags(tags=tags["sample"])
+            if (
+                not count_label_tags
+                and label_tags
+                and not label_tags["exclude"]
+                and label_tags["isMatching"]
+            ):
+                view = view.match_labels(tags=label_tags["values"])
+
+            if (
+                not count_label_tags
+                and label_tags
+                and label_tags["exclude"]
+                and label_tags["isMatching"]
+            ):
+                view = view.match_labels(tags=label_tags["values"], bool=False)
 
         stages, cleanup_fields, filtered_labels = _make_filter_stages(
             view,
@@ -306,8 +344,24 @@ def _make_filter_stages(
     else:
         frame_field_schema = None
 
+    label_tags_values = (
+        label_tags["values"] if label_tags is not None else None
+    )
+    label_tags_exclude = (
+        label_tags["exclude"] if label_tags is not None else None
+    )
+    label_tags_is_matching = (
+        label_tags["isMatching"] if label_tags is not None else None
+    )
+
     tag_expr = (F("tags") != None).if_else(
-        F("tags").contains(label_tags), None
+        F("tags").contains(label_tags_values), None
+    )
+
+    tag_expr = (
+        ~tag_expr
+        if label_tags_exclude and not label_tags_is_matching
+        else tag_expr
     )
     cache = {}
     stages = []
@@ -409,6 +463,9 @@ def _make_filter_stages(
                 stages.append(fosg.Match(expr))
 
     if label_tags is not None and hide_result:
+        is_matching = label_tags.get("isMatching", False)
+        exclude = label_tags.get("exclude", False)
+
         for path, _ in fosu.iter_label_fields(view):
             if hide_result:
                 new_field = _get_filtered_path(
@@ -431,14 +488,19 @@ def _make_filter_stages(
 
         match_exprs = []
         for path, _ in fosu.iter_label_fields(view):
-            match_exprs.append(
-                fosg._get_label_field_only_matches_expr(
-                    view,
-                    cache.get(path, path),
-                )
+            expr = fosg._get_label_field_only_matches_expr(
+                view,
+                cache.get(path, path),
             )
+            if exclude and is_matching:
+                # pylint: disable=invalid-unary-operand-type
+                expr = ~expr
 
-        stages.append(fosg.Match(F.any(match_exprs)))
+            match_exprs.append(expr)
+
+        if match_exprs:
+            matcher = F.all if exclude else F.any
+            stages.append(fosg.Match(matcher(match_exprs)))
 
     return stages, cleanup, filtered_labels
 
@@ -615,7 +677,13 @@ def _apply_none(expr, f, none):
 
 
 def _get_filtered_path(view, path, filtered_fields, label_tags):
-    if path not in filtered_fields and not label_tags:
+    if label_tags is not None:
+        excludes = label_tags.get("exclude", None)
+        label_tags = label_tags.get("values", None)
+    else:
+        excludes = None
+
+    if path not in filtered_fields and not label_tags and not excludes:
         return path
 
     if path.startswith(view._FRAMES_PREFIX):


Test patch:
diff --git a/app/packages/state/tests/recoil/sidebar.test.ts b/app/packages/state/tests/recoil/sidebar.test.ts
--- a/app/packages/state/tests/recoil/sidebar.test.ts
+++ b/app/packages/state/tests/recoil/sidebar.test.ts
@@ -211,25 +211,22 @@ describe("ResolveGroups works", () => {
   it("dataset groups should resolve when curent is undefined", () => {
     const test = sidebar.resolveGroups(mockDataset, undefined);
 
-    expect(test.length).toBe(6);
+    expect(test.length).toBe(5);
     expect(test[0].name).toBe("tags");
     expect(test[0].paths.length).toBe(0);
-    expect(test[1].name).toBe("label tags");
-    expect(test[1].paths.length).toBe(0);
-    expect(test[2].name).toBe("metadata");
-    expect(test[2].paths.length).toBe(5);
-    expect(test[3].name).toBe("labels");
-    expect(test[3].paths.length).toBe(2);
-    expect(test[4].name).toBe("primitives");
-    expect(test[4].paths.length).toBe(3);
-    expect(test[5].name).toBe("other");
-    expect(test[5].paths.length).toBe(2);
+    expect(test[1].name).toBe("metadata");
+    expect(test[1].paths.length).toBe(5);
+    expect(test[2].name).toBe("labels");
+    expect(test[2].paths.length).toBe(2);
+    expect(test[3].name).toBe("primitives");
+    expect(test[3].paths.length).toBe(3);
+    expect(test[4].name).toBe("other");
+    expect(test[4].paths.length).toBe(2);
   });
 
   it("when dataset appconfig does not have sidebarGroups settings, use default settings", () => {
     const mockSidebarGroups = [
       { name: "tags", paths: [], expanded: true },
-      { name: "label tags", paths: [], expanded: true },
       {
         name: "metadata",
         paths: [
@@ -260,12 +257,12 @@ describe("ResolveGroups works", () => {
 
     const test = sidebar.resolveGroups(mockDataset, mockSidebarGroups);
 
-    expect(test.length).toBe(8);
-    expect(test[5].name).toBe("other");
+    expect(test.length).toBe(7);
+    expect(test[4].name).toBe("other");
+    expect(test[4].expanded).toBeFalsy();
+    expect(test[5].name).toBe("test group a");
     expect(test[5].expanded).toBeFalsy();
-    expect(test[6].name).toBe("test group a");
+    expect(test[6].name).toBe("test group b");
     expect(test[6].expanded).toBeFalsy();
-    expect(test[7].name).toBe("test group b");
-    expect(test[7].expanded).toBeFalsy();
   });
 });
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
@@ -673,7 +673,13 @@ def test_extended_view_video_label_filters_aggregations(self):
 
     @drop_datasets
     def test_extended_view_video_match_label_tags_aggregations(self):
-        filters = {"tags": {"label": ["one"]}}
+        filters = {
+            "_label_tags": {
+                "values": ["one"],
+                "exclude": False,
+                "isMatching": True,
+            }
+        }
 
         dataset = fod.Dataset("test")
         dataset.media_type = "video"
@@ -811,7 +817,13 @@ def test_extended_view_video_match_label_tags_aggregations(self):
 
     @drop_datasets
     def test_extended_view_video_match_label_tags_samples(self):
-        filters = {"tags": {"label": ["one"]}}
+        filters = {
+            "_label_tags": {
+                "values": ["one"],
+                "exclude": False,
+                "isMatching": False,
+            }
+        }
 
         dataset = fod.Dataset("test")
         dataset.media_type = "video"