Repository: voxel51/fiftyone

Issue:
[FR] Add CVAT specific flags to tie attribute values to widgets in the CVAT editor
The CVAT editor contains a selection of widgets for attributes like `occluded`, `hidden`, or `locked` change the way that the labels are presented and interacted with in the editor.

![Screenshot from 2021-09-23 11-57-41](https://user-images.githubusercontent.com/21222883/134542172-750ba7a4-f24b-4019-8dff-b2abae9675f3.png)

It would be useful to allow an attribute like `occluded` to be uploaded and downloaded to FiftyOne and tied to the `occluded` widget in CVAT. One way this could be implemented is by letting the type of the attribute be the specific `widget` to tie the attribute to.

```python
import fiftyone as fo
import fiftyone.zoo as foz

dataset = foz.load_zoo_dataset("quickstart", max_samples=10)

label_schema = {
    "ground_truth": {
        "attributes": {
            "occluded": {
                "type": "occluded_widget",
            }
        }
    }
}

results = dataset.annotate(
    "widgets",
    label_schema=label_schema,
    launch_editor=True,
)

dataset.load_annotations("widgets", cleanup=True)
dataset.delete_annotation_run("widgets")
```



Discussion:

Resolving patch:
diff --git a/fiftyone/utils/cvat.py b/fiftyone/utils/cvat.py
--- a/fiftyone/utils/cvat.py
+++ b/fiftyone/utils/cvat.py
@@ -2437,7 +2437,7 @@ class CVATBackendConfig(foua.AnnotationBackendConfig):
         chunk_size (None): the number of frames to upload per ZIP chunk
         task_assignee (None): the username(s) to which the task(s) were
             assigned. This argument can be a list of usernames when annotating
-            videos as each video is uploaded to a separate task 
+            videos as each video is uploaded to a separate task
         job_assignees (None): a list of usernames to which jobs were assigned
         job_reviewers (None): a list of usernames to which job reviews were
             assigned
@@ -2526,7 +2526,13 @@ def supported_scalar_types(self):
 
     @property
     def supported_attr_types(self):
-        return ["text", "select", "radio", "checkbox"]
+        return [
+            "text",
+            "select",
+            "radio",
+            "checkbox",
+            "occluded",
+        ]
 
     @property
     def supports_keyframes(self):
@@ -2534,6 +2540,9 @@ def supports_keyframes(self):
 
     def recommend_attr_tool(self, name, value):
         if isinstance(value, bool):
+            if name == "occluded":
+                return {"type": "occluded"}
+
             return {"type": "checkbox"}
 
         return {"type": "text"}
@@ -2585,7 +2594,6 @@ def __init__(
         job_ids,
         frame_id_map,
         labels_task_map,
-        assigned_scalar_attrs,
         backend=None,
     ):
         super().__init__(samples, config, id_map, backend=backend)
@@ -2594,7 +2602,6 @@ def __init__(
         self.job_ids = job_ids
         self.frame_id_map = frame_id_map
         self.labels_task_map = labels_task_map
-        self.assigned_scalar_attrs = assigned_scalar_attrs
 
     def load_credentials(self, url=None, username=None, password=None):
         """Load the CVAT credentials from the given keyword arguments or the
@@ -2757,7 +2764,6 @@ def _from_dict(cls, d, samples, config):
             job_ids,
             frame_id_map,
             d["labels_task_map"],
-            d["assigned_scalar_attrs"],
         )
 
 
@@ -3179,7 +3185,6 @@ def upload_samples(self, samples, backend):
         job_ids = {}
         frame_id_map = {}
         labels_task_map = {}
-        assigned_scalar_attrs = {}
 
         num_samples = len(samples)
         is_video = samples.media_type == fom.VIDEO
@@ -3201,12 +3206,11 @@ def upload_samples(self, samples, backend):
             is_existing_field = label_info["existing_field"]
             only_keyframes = label_info.get("only_keyframes", False)
 
-            cvat_schema, assign_scalar_attrs = self._build_cvat_schema(
-                label_field, label_info
-            )
-
-            if label_type == "scalar":
-                assigned_scalar_attrs[label_field] = assign_scalar_attrs
+            (
+                cvat_schema,
+                assign_scalar_attrs,
+                occluded_attrs,
+            ) = self._build_cvat_schema(label_field, label_info)
 
             labels_task_map[label_field] = []
 
@@ -3248,6 +3252,7 @@ def upload_samples(self, samples, backend):
                             cvat_schema,
                             load_tracks=True,
                             only_keyframes=only_keyframes,
+                            occluded_attrs=occluded_attrs,
                         )
                     else:
                         # Shape annotations
@@ -3259,6 +3264,7 @@ def upload_samples(self, samples, backend):
                             label_field,
                             label_info,
                             cvat_schema,
+                            occluded_attrs=occluded_attrs,
                         )
 
                     id_map[label_field] = _id_map
@@ -3366,7 +3372,6 @@ def upload_samples(self, samples, backend):
             job_ids,
             frame_id_map,
             labels_task_map,
-            assigned_scalar_attrs,
             backend=backend,
         )
 
@@ -3385,20 +3390,30 @@ def download_annotations(self, results):
         task_ids = results.task_ids
         frame_id_map = results.frame_id_map
         labels_task_map = results.labels_task_map
-        assigned_scalar_attrs = results.assigned_scalar_attrs
 
-        annotations = {}
+        assigned_scalar_attrs = {}
+        occluded_attrs = {}
+        for label_field, label_info in label_schema.items():
+            _, scalar_attrs, occ_attrs = self._build_cvat_schema(
+                label_field, label_info
+            )
+
+            assigned_scalar_attrs[label_field] = scalar_attrs
+            occluded_attrs[label_field] = occ_attrs
 
         labels_task_map_rev = {}
         for lf, tasks in labels_task_map.items():
             for task in tasks:
                 labels_task_map_rev[task] = lf
 
+        annotations = {}
+
         for task_id in task_ids:
             label_field = labels_task_map_rev[task_id]
             label_info = label_schema[label_field]
             label_type = label_info["type"]
             scalar_attrs = assigned_scalar_attrs.get(label_field, False)
+            occ_attrs = occluded_attrs.get(label_field, {})
             _id_map = id_map.get(label_field, {})
 
             # Download task data
@@ -3447,6 +3462,7 @@ def download_annotations(self, results):
                 attr_id_map,
                 frames,
                 assigned_scalar_attrs=scalar_attrs,
+                occluded_attrs=occ_attrs,
             )
             label_field_results = self._merge_results(
                 label_field_results, shape_results
@@ -3472,6 +3488,7 @@ def download_annotations(self, results):
                     assigned_scalar_attrs=scalar_attrs,
                     track_index=track_index,
                     immutable_attrs=immutable_attrs,
+                    occluded_attrs=occ_attrs,
                 )
                 label_field_results = self._merge_results(
                     label_field_results, track_shape_results
@@ -3577,6 +3594,7 @@ def _parse_shapes_tags(
         assigned_scalar_attrs=False,
         track_index=None,
         immutable_attrs=None,
+        occluded_attrs=None,
     ):
         results = {}
         prev_type = None
@@ -3612,6 +3630,7 @@ def _parse_shapes_tags(
                 assigned_scalar_attrs=assigned_scalar_attrs,
                 track_index=track_index,
                 immutable_attrs=immutable_attrs,
+                occluded_attrs=occluded_attrs,
             )
 
         # For non-outside tracked objects, the last track goes to the end of
@@ -3636,6 +3655,7 @@ def _parse_shapes_tags(
                     assigned_scalar_attrs=assigned_scalar_attrs,
                     track_index=track_index,
                     immutable_attrs=immutable_attrs,
+                    occluded_attrs=occluded_attrs,
                 )
 
         return results
@@ -3655,6 +3675,7 @@ def _parse_annotation(
         assigned_scalar_attrs=False,
         track_index=None,
         immutable_attrs=None,
+        occluded_attrs=None,
     ):
         frame = anno["frame"]
         if len(frames) > frame:
@@ -3691,6 +3712,7 @@ def _parse_annotation(
                 metadata,
                 index=track_index,
                 immutable_attrs=immutable_attrs,
+                occluded_attrs=occluded_attrs,
             )
 
             # Non-keyframe annotations were interpolated from keyframes but
@@ -3862,7 +3884,9 @@ def _build_cvat_schema(self, label_field, label_info):
         label_type = label_info["type"]
         is_existing_field = label_info["existing_field"]
         classes = label_info["classes"]
-        attributes = self._to_cvat_attributes(label_info["attributes"])
+        attributes, occluded_attr_name = self._to_cvat_attributes(
+            label_info["attributes"]
+        )
 
         # Must track label IDs for existing label fields
         if is_existing_field and label_type != "scalar":
@@ -3878,9 +3902,12 @@ def _build_cvat_schema(self, label_field, label_info):
                 "mutable": True,
             }
 
-        # True: scalars are annotated as tag attributes
-        # False: scalars are annotated as tag labels
-        assign_scalar_attrs = not bool(classes)
+        if label_type == "scalar":
+            # True: scalars are annotated as tag attributes
+            # False: scalars are annotated as tag labels
+            assign_scalar_attrs = not bool(classes)
+        else:
+            assign_scalar_attrs = None
 
         if not classes:
             classes = [label_field]
@@ -3893,6 +3920,7 @@ def _build_cvat_schema(self, label_field, label_info):
                 }
 
         cvat_schema = {}
+        occluded_attrs = {}
 
         # Global attributes
         for _class in classes:
@@ -3903,6 +3931,8 @@ def _build_cvat_schema(self, label_field, label_info):
 
             for name in _classes:
                 cvat_schema[name] = deepcopy(attributes)
+                if occluded_attr_name is not None:
+                    occluded_attrs[name] = occluded_attr_name
 
         # Class-specific attributes
         for _class in classes:
@@ -3910,7 +3940,9 @@ def _build_cvat_schema(self, label_field, label_info):
                 continue
 
             _classes = _class["classes"]
-            _attrs = self._to_cvat_attributes(_class["attributes"])
+            _attrs, _occluded_attr_name = self._to_cvat_attributes(
+                _class["attributes"]
+            )
 
             if "label_id" in _attrs:
                 raise ValueError(
@@ -3920,16 +3952,24 @@ def _build_cvat_schema(self, label_field, label_info):
 
             for name in _classes:
                 cvat_schema[name].update(_attrs)
+                if _occluded_attr_name is not None:
+                    occluded_attrs[name] = _occluded_attr_name
 
-        return cvat_schema, assign_scalar_attrs
+        return cvat_schema, assign_scalar_attrs, occluded_attrs
 
     def _to_cvat_attributes(self, attributes):
         cvat_attrs = {}
+        occluded_attr_name = None
         for attr_name, info in attributes.items():
             cvat_attr = {"name": attr_name, "mutable": True}
+            is_occluded = False
             for attr_key, val in info.items():
                 if attr_key == "type":
-                    cvat_attr["input_type"] = val
+                    if val == "occluded":
+                        occluded_attr_name = attr_name
+                        is_occluded = True
+                    else:
+                        cvat_attr["input_type"] = val
                 elif attr_key == "values":
                     cvat_attr["values"] = [str(v) for v in val]
                 elif attr_key == "default":
@@ -3937,9 +3977,10 @@ def _to_cvat_attributes(self, attributes):
                 elif attr_key == "mutable":
                     cvat_attr["mutable"] = bool(val)
 
-            cvat_attrs[attr_name] = cvat_attr
+            if not is_occluded:
+                cvat_attrs[attr_name] = cvat_attr
 
-        return cvat_attrs
+        return cvat_attrs, occluded_attr_name
 
     def _create_shapes_tags_tracks(
         self,
@@ -3950,6 +3991,7 @@ def _create_shapes_tags_tracks(
         assign_scalar_attrs=False,
         load_tracks=False,
         only_keyframes=False,
+        occluded_attrs=None,
     ):
         label_type = label_info["type"]
         classes = label_info["classes"]
@@ -3998,6 +4040,7 @@ def _create_shapes_tags_tracks(
                 ):
                     kwargs["load_tracks"] = load_tracks
                     kwargs["only_keyframes"] = only_keyframes
+                    kwargs["occluded_attrs"] = occluded_attrs
 
                 if label_type == "scalar":
                     labels = label
@@ -4124,6 +4167,7 @@ def _create_classification_tags(
                 attributes,
                 _,
                 _remapped_attrs,
+                _,
             ) = self._create_attributes(cn, cvat_schema)
 
             if class_name is None:
@@ -4156,6 +4200,7 @@ def _create_detection_shapes(
         label_id=None,
         load_tracks=False,
         only_keyframes=False,
+        occluded_attrs=None,
     ):
         ids = []
         shapes = []
@@ -4168,7 +4213,13 @@ def _create_detection_shapes(
                 attributes,
                 immutable_attrs,
                 _remapped_attrs,
-            ) = self._create_attributes(det, cvat_schema, label_id=label_id)
+                is_occluded,
+            ) = self._create_attributes(
+                det,
+                cvat_schema,
+                label_id=label_id,
+                occluded_attrs=occluded_attrs,
+            )
 
             if class_name is None:
                 continue
@@ -4198,7 +4249,7 @@ def _create_detection_shapes(
                 curr_shapes.append(
                     {
                         "type": "rectangle",
-                        "occluded": False,
+                        "occluded": is_occluded,
                         "z_order": 0,
                         "points": bbox,
                         "label_id": class_name,
@@ -4227,7 +4278,7 @@ def _create_detection_shapes(
                     curr_shapes.append(
                         {
                             "type": "polygon",
-                            "occluded": False,
+                            "occluded": is_occluded,
                             "z_order": 0,
                             "points": flattened_points,
                             "label_id": class_name,
@@ -4267,6 +4318,7 @@ def _create_keypoint_shapes(
         label_type=None,
         load_tracks=False,
         only_keyframes=False,
+        occluded_attrs=None,
     ):
         ids = []
         shapes = []
@@ -4279,7 +4331,10 @@ def _create_keypoint_shapes(
                 attributes,
                 immutable_attrs,
                 _remapped_attrs,
-            ) = self._create_attributes(kp, cvat_schema)
+                is_occluded,
+            ) = self._create_attributes(
+                kp, cvat_schema, occluded_attrs=occluded_attrs
+            )
 
             if class_name is None:
                 continue
@@ -4300,7 +4355,7 @@ def _create_keypoint_shapes(
 
             shape = {
                 "type": "points",
-                "occluded": False,
+                "occluded": is_occluded,
                 "z_order": 0,
                 "points": flattened_points,
                 "label_id": class_name,
@@ -4336,6 +4391,7 @@ def _create_polyline_shapes(
         label_type=None,
         load_tracks=False,
         only_keyframes=False,
+        occluded_attrs=None,
     ):
         ids = []
         shapes = []
@@ -4348,7 +4404,10 @@ def _create_polyline_shapes(
                 attributes,
                 immutable_attrs,
                 _remapped_attrs,
-            ) = self._create_attributes(poly, cvat_schema)
+                is_occluded,
+            ) = self._create_attributes(
+                poly, cvat_schema, occluded_attrs=occluded_attrs
+            )
 
             if class_name is None:
                 continue
@@ -4377,7 +4436,7 @@ def _create_polyline_shapes(
 
                 shape = {
                     "type": "polygon" if poly.filled else "polyline",
-                    "occluded": False,
+                    "occluded": is_occluded,
                     "z_order": 0,
                     "points": flattened_points,
                     "label_id": class_name,
@@ -4431,9 +4490,11 @@ def _create_segmentation_shapes(
 
         return label_id, shapes, tracks, remapped_attrs
 
-    def _create_attributes(self, label, cvat_schema, label_id=None):
+    def _create_attributes(
+        self, label, cvat_schema, label_id=None, occluded_attrs=None
+    ):
         if label.label not in cvat_schema:
-            return None, None, None, None
+            return None, None, None, None, None
 
         class_name = label.label
         attr_schema = cvat_schema[class_name]
@@ -4463,7 +4524,19 @@ def _create_attributes(self, label, cvat_schema, label_id=None):
             else:
                 immutable_attrs.append(attr_dict)
 
-        return class_name, label_attrs, immutable_attrs, remapped_attrs
+        is_occluded = False
+        if occluded_attrs is not None:
+            attr_name = occluded_attrs.get(class_name, None)
+            if attr_name is not None:
+                is_occluded = bool(label.get_attribute_value(attr_name, False))
+
+        return (
+            class_name,
+            label_attrs,
+            immutable_attrs,
+            remapped_attrs,
+            is_occluded,
+        )
 
     def _add_shapes_to_tracks(
         self, tracks, shapes, class_name, index, frame_id, immutable_attrs
@@ -4653,6 +4726,8 @@ class CVATShape(CVATLabel):
         index (None): the tracking index of the shape
         immutable_attrs (None): immutable attributes inherited by this shape
             from its track
+        occluded_attrs (None): a dictonary mapping class names to the
+            corresponding attribute linked to the CVAT occlusion widget, if any
     """
 
     def __init__(
@@ -4663,6 +4738,7 @@ def __init__(
         metadata,
         index=None,
         immutable_attrs=None,
+        occluded_attrs=None,
     ):
         super().__init__(
             label_dict, class_map, attr_id_map, attributes=immutable_attrs
@@ -4672,6 +4748,12 @@ def __init__(
         self.points = label_dict["points"]
         self.index = index
 
+        # Parse occluded attribute, if necessary
+        if occluded_attrs is not None:
+            occluded_attr_name = occluded_attrs.get(self.label, None)
+            if occluded_attr_name:
+                self.attributes[occluded_attr_name] = label_dict["occluded"]
+
     def _to_pairs_of_points(self, points):
         reshaped_points = np.reshape(points, (-1, 2))
         return reshaped_points.tolist()


Test patch: