Repository: voxel51/fiftyone

Issue:
Add basic support for visualizing distributions of label and numeric fields in the dashboard
Proposed MVP implementation after our meeting on June 1st:
- https://docs.google.com/presentation/d/1LcBiyWTviI8EPZh8mAd_C_ekckoePxy0QIN3gE7WMIM/edit#slide=id.g862d95b333_6_597
- https://docs.google.com/presentation/d/1LcBiyWTviI8EPZh8mAd_C_ekckoePxy0QIN3gE7WMIM/edit#slide=id.g862d95b333_6_871

The tab headings are up for discussion; unsure how to best organize.

MVP field types for which to support 
- histograms of `fiftyone.core.label.Classification` labels
- histograms of `fiftyone.core.label.Detections` labels
- histograms of string list (e.g., `tags`) values
- histograms of numeric (int, float) values


Discussion:

Resolving patch:
diff --git a/fiftyone/core/view.py b/fiftyone/core/view.py
--- a/fiftyone/core/view.py
+++ b/fiftyone/core/view.py
@@ -26,6 +26,7 @@
 from pymongo import ASCENDING, DESCENDING
 
 import fiftyone.core.collections as foc
+import fiftyone.core.fields as fof
 
 
 def _make_registrar():
@@ -459,12 +460,6 @@ def _slice(self, s):
 
         return self.skip(start).limit(stop - start)
 
-    def _get_field_distributions(self):
-        return list(self.aggregate(_FIELD_DISTRIBUTIONS_PIPELINE))
-
-    def _get_facets(self):
-        return list(self.aggregate(_FACETS_PIPELINE))
-
     def _copy_with_new_stage(self, stage):
         view = copy(self)
         view._pipeline.append(stage)
@@ -476,42 +471,3 @@ def _get_latest_offset(self):
                 return stage["$skip"]
 
         return 0
-
-
-_FIELD_DISTRIBUTIONS_PIPELINE = [
-    {"$project": {"field": {"$objectToArray": "$$ROOT"}}},
-    {"$unwind": "$field"},
-    {"$project": {"field": "$field.k", "detection": "$field.v.detections"}},
-    {"$unwind": "$detection"},
-    {
-        "$group": {
-            "_id": {"field": "$field", "label": "$detection.label"},
-            "count": {"$sum": 1},
-        }
-    },
-    {
-        "$group": {
-            "_id": "$_id.field",
-            "labels": {"$push": {"label": "$_id.label", "count": "$count"}},
-        }
-    },
-]
-
-
-_FACETS_PIPELINE = [
-    {
-        "$facet": {
-            "tags": [
-                {"$project": {"tag": "$tags"}},
-                {
-                    "$unwind": {
-                        "path": "$tag",
-                        "preserveNullAndEmptyArrays": True,
-                    }
-                },
-                {"$group": {"_id": "$tag", "count": {"$sum": 1}}},
-                {"$sort": {"_id": 1}},
-            ],
-        }
-    }
-]
diff --git a/fiftyone/server/main.py b/fiftyone/server/main.py
--- a/fiftyone/server/main.py
+++ b/fiftyone/server/main.py
@@ -25,8 +25,11 @@
 from flask_socketio import emit, Namespace, SocketIO
 
 os.environ["FIFTYONE_SERVER"] = "1"
+import fiftyone.core.fields as fof
 import fiftyone.core.state as fos
+
 from util import get_image_size
+from pipelines import DISTRIBUTION_PIPELINES, LABELS, SCALARS
 
 logger = logging.getLogger(__name__)
 
@@ -173,14 +176,14 @@ def on_lengths(self, _):
             return []
         return {"labels": view.get_label_fields(), "tags": view.get_tags()}
 
-    def on_get_field_distributions(self, _):
-        """Gets the labels distributions for the current state.
+    def on_get_distributions(self, group):
+        """Gets the distributions for the current state with respect to a group,
 
         Args:
-            _: the message, which is not used
+            group: one of "labels", "tags", or "scalars"
 
         Returns:
-            the list of label distributions
+            a list of distributions
         """
         state = fos.StateDescription.from_dict(self.state)
         if state.view is not None:
@@ -190,7 +193,7 @@ def on_get_field_distributions(self, _):
         else:
             return []
 
-        return view._get_field_distributions()
+        return _get_distributions(view, group)
 
     def on_get_facets(self, _):
         """Gets the facets for the current state.
@@ -224,6 +227,103 @@ def on_set_facets(self, facets):
         emit("update", self.state, broadcast=True, include_self=True)
 
 
+def _get_distributions(view, group):
+    pipeline = DISTRIBUTION_PIPELINES[group]
+
+    # we add a sub-pipeline for each numeric as it looks like multiple
+    # buckets in a single pipeline is not supported
+    if group == SCALARS:
+        _numeric_distribution_pipelines(view, pipeline)
+
+    result = list(view.aggregate(pipeline))
+
+    if group in {LABELS, SCALARS}:
+        new_result = []
+        for f in result[0].values():
+            new_result += f
+        result = new_result
+
+    if group != SCALARS:
+        for idx, dist in enumerate(result):
+            result[idx]["data"] = sorted(
+                result[idx]["data"], key=lambda c: c["count"], reverse=True
+            )
+
+    return sorted(result, key=lambda d: d["name"])
+
+
+def _numeric_bounds(view, numerics):
+    bounds_pipeline = [{"$facet": {}}]
+    for idx, (k, v) in enumerate(numerics.items()):
+        bounds_pipeline[0]["$facet"]["numeric-%d" % idx] = [
+            {
+                "$group": {
+                    "_id": k,
+                    "min": {"$min": "$%s" % k},
+                    "max": {"$max": "$%s" % k},
+                },
+            }
+        ]
+
+    return list(view.aggregate(bounds_pipeline))[0] if len(numerics) else {}
+
+
+def _numeric_distribution_pipelines(view, pipeline, buckets=50):
+    numerics = view._dataset.get_field_schema(ftype=fof.IntField)
+    numerics.update(view._dataset.get_field_schema(ftype=fof.FloatField))
+
+    # here we query the min and max for each numeric field
+    # unfortunately, it looks like this has to be a separate query
+    bounds = _numeric_bounds(view, numerics)
+
+    # for each numeric field, build the boundaries array with the
+    # min/max results when adding the field's sub-pipeline
+    for idx, (k, v) in enumerate(numerics.items()):
+        sub_pipeline = "numeric-%d" % idx
+        field_bounds = bounds[sub_pipeline][0]
+        mn = field_bounds["min"]
+        mx = field_bounds["max"]
+        step = (mx - mn) / buckets
+        boundaries = [mn + step * s for s in range(0, buckets)]
+
+        pipeline[0]["$facet"][sub_pipeline] = [
+            {
+                "$bucket": {
+                    "groupBy": "$%s" % k,
+                    "boundaries": boundaries,
+                    "default": "null",
+                    "output": {"count": {"$sum": 1}},
+                }
+            },
+            {
+                "$group": {
+                    "_id": k,
+                    "data": {
+                        "$push": {
+                            "key": {
+                                "$cond": [
+                                    {"$ne": ["$_id", "null"]},
+                                    {"$add": ["$_id", step / 2]},
+                                    "null",
+                                ]
+                            },
+                            "count": "$count",
+                        }
+                    },
+                }
+            },
+            {
+                "$project": {
+                    "name": k,
+                    "type": v.__class__.__name__[
+                        : -len("Field")  # grab field type from the class
+                    ].lower(),
+                    "data": "$data",
+                }
+            },
+        ]
+
+
 socketio.on_namespace(StateController("/state"))
 
 
diff --git a/fiftyone/server/pipelines.py b/fiftyone/server/pipelines.py
new file mode 100644
--- /dev/null
+++ b/fiftyone/server/pipelines.py
@@ -0,0 +1,164 @@
+"""
+FiftyOne App pipeline definiutions.
+
+| Copyright 2017-2020, Voxel51, Inc.
+| `voxel51.com <https://voxel51.com/>`_
+|
+"""
+LABELS = "labels"
+SCALARS = "scalars"
+TAGS = "tags"
+
+
+DISTRIBUTION_PIPELINES = {
+    LABELS: [
+        {
+            "$facet": {
+                "detections": [
+                    {"$project": {"field": {"$objectToArray": "$$ROOT"}}},
+                    {"$unwind": "$field"},
+                    {"$match": {"field.v._cls": "Detections"}},
+                    {
+                        "$project": {
+                            "field": "$field.k",
+                            "detection": "$field.v.detections",
+                        }
+                    },
+                    {"$unwind": "$detection"},
+                    {
+                        "$group": {
+                            "_id": {
+                                "field": "$field",
+                                "label": "$detection.label",
+                            },
+                            "count": {"$sum": 1},
+                        }
+                    },
+                    {
+                        "$group": {
+                            "_id": "$_id.field",
+                            "data": {
+                                "$push": {
+                                    "key": "$_id.label",
+                                    "count": "$count",
+                                }
+                            },
+                        }
+                    },
+                    {
+                        "$project": {
+                            "name": "$_id",
+                            "type": "detection",
+                            "data": "$data",
+                        }
+                    },
+                ],
+                "classifications": [
+                    {"$project": {"field": {"$objectToArray": "$$ROOT"}}},
+                    {"$unwind": "$field"},
+                    {"$match": {"field.v._cls": "Classification"}},
+                    {
+                        "$project": {
+                            "field": "$field.k",
+                            "label": "$field.v.label",
+                        }
+                    },
+                    {
+                        "$group": {
+                            "_id": {"field": "$field", "label": "$label"},
+                            "count": {"$sum": 1},
+                        }
+                    },
+                    {
+                        "$group": {
+                            "_id": "$_id.field",
+                            "data": {
+                                "$push": {
+                                    "key": "$_id.label",
+                                    "count": "$count",
+                                }
+                            },
+                        }
+                    },
+                    {
+                        "$project": {
+                            "name": "$_id",
+                            "type": "classification",
+                            "data": "$data",
+                        }
+                    },
+                ],
+            }
+        }
+    ],
+    TAGS: [
+        {"$project": {"tag": "$tags"}},
+        {"$unwind": "$tag"},
+        {"$group": {"_id": "$tag", "count": {"$sum": 1}}},
+        {"$project": {"result": "$$ROOT", "field": "tags"}},
+        {
+            "$group": {
+                "_id": "$field",
+                "data": {
+                    "$push": {"key": "$result._id", "count": "$result.count"}
+                },
+            }
+        },
+        {"$project": {"name": "$_id", "type": "tag", "data": "$data"}},
+    ],
+    SCALARS: [
+        {
+            "$facet": {
+                "groupings": [
+                    {"$project": {"field": {"$objectToArray": "$$ROOT"}}},
+                    {"$unwind": "$field"},
+                    {"$match": {"field.k": {"$ne": "filepath"}}},
+                    {
+                        "$project": {
+                            "field": "$field",
+                            "type": {"$type": "$field.v"},
+                        }
+                    },
+                    {"$match": {"type": {"$ne": "array"}}},
+                    {
+                        "$match": {
+                            "field.v": {"$type": "bool"}
+                        },  # @todo: support strings
+                    },
+                    {"$project": {"field": "$field.k", "label": "$field.v"}},
+                    {
+                        "$group": {
+                            "_id": {
+                                "group": "$field",
+                                "label": "$label",
+                                "type": {"$type": "$label"},
+                            },
+                            "count": {"$sum": 1},
+                        }
+                    },
+                    {
+                        "$group": {
+                            "_id": {
+                                "group": "$_id.group",
+                                "type": "$_id.type",
+                            },
+                            "data": {
+                                "$push": {
+                                    "key": "$_id.label",
+                                    "count": "$count",
+                                }
+                            },
+                        }
+                    },
+                    {
+                        "$project": {
+                            "name": "$_id.group",
+                            "type": "$_id.type",
+                            "data": "$data",
+                        }
+                    },
+                ],
+            }
+        }
+    ],
+}


Test patch: