Repository: voxel51/fiftyone

Issue:
[BUG] Not loading class-specific attributes after annotation
The following example from PR #1305 is not working when attempting to load modifications to class-specific attributes. For example, `colorful`, `type`, and `age` are not being updated when downloading annotations, but `iscrowd` and `area` are updated.

```python
import random
import fiftyone as fo
import fiftyone.zoo as foz
from fiftyone import ViewField as F

dataset = foz.load_zoo_dataset("quickstart")
dataset = dataset[-1:].select_fields("ground_truth").clone()

for sample in dataset:
    for detection in sample.ground_truth.detections:
        if detection.label == "car":
            detection.type = random.choice(["sedan", "suv", "truck"])
        elif detection.label == "kite":
            detection.colorful = random.choice([False, True])
        elif detection.label == "person":
            detection.age = random.randint(1, 51)

    sample.save()

dataset.annotate(
    "test",
    label_field="ground_truth",
    classes=[
        {"classes": ["car"], "attributes": True},
        {"classes": ["kite"], "attributes": True},
        {"classes": ["person"], "attributes": True},
    ],
    attributes=["area", "iscrowd"],
    launch_editor=True,
)
print(dataset.get_annotation_info("test"))

# Create annotations in CVAT...

dataset.load_annotations("test", cleanup=True)
sesion = fo.launch_app(dataset)
```



Discussion:

Resolving patch:
diff --git a/fiftyone/utils/annotations.py b/fiftyone/utils/annotations.py
--- a/fiftyone/utils/annotations.py
+++ b/fiftyone/utils/annotations.py
@@ -940,6 +940,7 @@ def load_annotations(
 
     for label_field, label_info in label_schema.items():
         label_type = label_info["type"]
+        global_attrs, class_attrs = _parse_attributes(label_info)
         allow_additions = label_info.get("allow_additions", True)
 
         expected_type = _RETURN_TYPES_MAP[label_type]
@@ -954,7 +955,11 @@ def load_annotations(
                 # Expected labels
                 if label_type == "scalar":
                     _merge_scalars(
-                        samples, annos, results, label_field, label_info,
+                        samples,
+                        annos,
+                        results,
+                        label_field,
+                        label_info=label_info,
                     )
                 else:
                     _merge_labels(
@@ -963,7 +968,9 @@ def load_annotations(
                         results,
                         label_field,
                         label_type,
-                        label_info,
+                        label_info=label_info,
+                        global_attrs=global_attrs,
+                        class_attrs=class_attrs,
                     )
             else:
                 # Unexpected labels
@@ -976,19 +983,10 @@ def load_annotations(
 
                 if new_field:
                     if anno_type == "scalar":
-                        _label_info = {}
-                        _merge_scalars(
-                            samples, annos, results, new_field, _label_info
-                        )
+                        _merge_scalars(samples, annos, results, new_field)
                     else:
-                        _label_info = {"attributes": None}  # all attributes
                         _merge_labels(
-                            samples,
-                            annos,
-                            results,
-                            new_field,
-                            anno_type,
-                            _label_info,
+                            samples, annos, results, new_field, anno_type
                         )
                 else:
                     logger.info(
@@ -1004,6 +1002,31 @@ def load_annotations(
         results.cleanup()
 
 
+def _parse_attributes(label_info):
+    classes = label_info.get("classes", [])
+    attributes = label_info.get("attributes", {})
+    class_attrs = defaultdict(list)
+
+    global_attrs = _get_writeable_attributes(attributes)
+
+    # Parse per-class attributes
+    for _class in classes:
+        if etau.is_str(_class):
+            continue
+
+        _classes = _class["classes"]
+        _attrs = _get_writeable_attributes(_class["attributes"])
+
+        for name in _classes:
+            class_attrs[name].extend(_attrs)
+
+    return global_attrs, dict(class_attrs)
+
+
+def _get_writeable_attributes(attributes):
+    return [k for k, v in attributes.items() if not v.get("read_only", False)]
+
+
 def _prompt_field(samples, label_type, label_field, label_schema):
     new_field = input(
         "Found unexpected labels of type '%s' when loading annotations for "
@@ -1077,7 +1100,10 @@ def _prompt_field(samples, label_type, label_field, label_schema):
     return new_field
 
 
-def _merge_scalars(samples, anno_dict, results, label_field, label_info):
+def _merge_scalars(samples, anno_dict, results, label_field, label_info=None):
+    if label_info is None:
+        label_info = {}
+
     allow_additions = label_info.get("allow_additions", True)
     allow_deletions = label_info.get("allow_deletions", True)
 
@@ -1159,23 +1185,24 @@ def _merge_scalars(samples, anno_dict, results, label_field, label_info):
 
 
 def _merge_labels(
-    samples, anno_dict, results, label_field, label_type, label_info
+    samples,
+    anno_dict,
+    results,
+    label_field,
+    label_type,
+    label_info=None,
+    global_attrs=None,
+    class_attrs=None,
 ):
-    attributes = label_info.get("attributes", {})
+    if label_info is None:
+        label_info = {}
+
     only_keyframes = label_info.get("only_keyframes", False)
     allow_additions = label_info.get("allow_additions", True)
     allow_deletions = label_info.get("allow_deletions", True)
     allow_label_edits = label_info.get("allow_label_edits", True)
     allow_spatial_edits = label_info.get("allow_spatial_edits", True)
 
-    # Omit read-only attributes
-    if isinstance(attributes, dict):
-        attributes = {
-            k: v
-            for k, v in attributes.items()
-            if not v.get("read_only", False)
-        }
-
     fo_label_type = _LABEL_TYPES_MAP[label_type]
     if issubclass(fo_label_type, fol._LABEL_LIST_FIELDS):
         is_list = True
@@ -1281,7 +1308,8 @@ def _merge_labels(
                         _merge_label(
                             label,
                             anno_label,
-                            attributes=attributes,
+                            global_attrs=global_attrs,
+                            class_attrs=class_attrs,
                             allow_label_edits=allow_label_edits,
                             allow_spatial_edits=allow_spatial_edits,
                             only_keyframes=only_keyframes,
@@ -1344,7 +1372,8 @@ def _ensure_label_field(samples, label_field, fo_label_type):
 def _merge_label(
     label,
     anno_label,
-    attributes=None,
+    global_attrs=None,
+    class_attrs=None,
     allow_label_edits=True,
     allow_spatial_edits=True,
     only_keyframes=False,
@@ -1360,12 +1389,16 @@ def _merge_label(
     if only_keyframes:
         label.keyframe = anno_label.get_attribute_value("keyframe", None)
 
-    if attributes is not None:
-        for name in attributes:
-            value = anno_label.get_attribute_value(name, None)
+    if global_attrs is None:
+        # All attributes
+        for name, value in anno_label.iter_attributes():
             label.set_attribute_value(name, value)
     else:
-        for name, value in anno_label.iter_attributes():
+        # Global attributes + class-specific attributes
+        _class = label.get_attribute_value("label", None)
+        attr_names = global_attrs + class_attrs.get(_class, [])
+        for name in attr_names:
+            value = anno_label.get_attribute_value(name, None)
             label.set_attribute_value(name, value)
 
 


Test patch: