Repository: voxel51/fiftyone

Issue:
[FR] Import tags when loading a CVATImageDataset
Currently, tags are not being loaded from CVAT datasets. These should be loaded into FiftyOne as either `Classification` or primitive fields.


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
@@ -564,6 +564,7 @@ def has_image_metadata(self):
     @property
     def label_cls(self):
         return {
+            "classifications": fol.Classifications,
             "detections": fol.Detections,
             "polylines": fol.Polylines,
             "keypoints": fol.Keypoints,
@@ -880,6 +881,7 @@ def requires_image_metadata(self):
     @property
     def label_cls(self):
         return {
+            "classifications": fol.Classifications,
             "detections": fol.Detections,
             "polylines": fol.Polylines,
             "keypoints": fol.Keypoints,
@@ -1288,6 +1290,7 @@ class CVATImage(object):
         name: the filename of the image
         width: the width of the image, in pixels
         height: the height of the image, in pixels
+        tags (None): a list of :class:`CVATImageTag` instances
         boxes (None): a list of :class:`CVATImageBox` instances
         polygons (None): a list of :class:`CVATImagePolygon` instances
         polylines (None): a list of :class:`CVATImagePolyline` instances
@@ -1301,6 +1304,7 @@ def __init__(
         name,
         width,
         height,
+        tags=None,
         boxes=None,
         polygons=None,
         polylines=None,
@@ -1312,11 +1316,17 @@ def __init__(
         self.subset = subset
         self.width = width
         self.height = height
+        self.tags = tags or []
         self.boxes = boxes or []
         self.polygons = polygons or []
         self.polylines = polylines or []
         self.points = points or []
 
+    @property
+    def has_tags(self):
+        """Whether this image has tags."""
+        return bool(self.tags)
+
     @property
     def has_boxes(self):
         """Whether this image has 2D boxes."""
@@ -1339,7 +1349,7 @@ def iter_annos(self):
             an iterator that emits :class:`CVATImageAnno` instances
         """
         return itertools.chain(
-            self.boxes, self.polygons, self.polylines, self.points
+            self.tags, self.boxes, self.polygons, self.polylines, self.points
         )
 
     def get_image_metadata(self):
@@ -1363,6 +1373,12 @@ def to_labels(self):
 
         labels = {}
 
+        if self.tags:
+            tags = [t.to_classification() for t in self.tags]
+            labels["classifications"] = fol.Classifications(
+                classifications=tags
+            )
+
         if self.boxes:
             detections = [b.to_detection(frame_size) for b in self.boxes]
             labels["detections"] = fol.Detections(detections=detections)
@@ -1394,12 +1410,17 @@ def from_labels(cls, labels, metadata):
         width = metadata.width
         height = metadata.height
 
+        _classifications = []
         _detections = []
         _polygons = []
         _polylines = []
         _keypoints = []
         for _labels in labels.values():
-            if isinstance(_labels, fol.Detection):
+            if isinstance(_labels, fol.Classification):
+                _classifications.append(_labels)
+            elif isinstance(_labels, fol.Classifications):
+                _classifications.extend(_labels.classifications)
+            elif isinstance(_labels, fol.Detection):
                 _detections.append(_labels)
             elif isinstance(_labels, fol.Detections):
                 _detections.extend(_labels.detections)
@@ -1424,6 +1445,8 @@ def from_labels(cls, labels, metadata):
                 )
                 warnings.warn(msg)
 
+        tags = [CVATImageTag.from_classification(c) for c in _classifications]
+
         boxes = [CVATImageBox.from_detection(d, metadata) for d in _detections]
 
         polygons = []
@@ -1443,6 +1466,7 @@ def from_labels(cls, labels, metadata):
             None,
             width,
             height,
+            tags=tags,
             boxes=boxes,
             polygons=polygons,
             polylines=polylines,
@@ -1466,6 +1490,10 @@ def from_image_dict(cls, d):
         width = int(d["@width"])
         height = int(d["@height"])
 
+        tags = []
+        for td in _ensure_list(d.get("tag", [])):
+            tags.append(CVATImageTag.from_tag_dict(td))
+
         boxes = []
         for bd in _ensure_list(d.get("box", [])):
             boxes.append(CVATImageBox.from_box_dict(bd))
@@ -1487,6 +1515,7 @@ def from_image_dict(cls, d):
             name,
             width,
             height,
+            tags=tags,
             boxes=boxes,
             polygons=polygons,
             polylines=polylines,
@@ -1588,6 +1617,55 @@ def _parse_anno_dict(d):
         return occluded, attributes
 
 
+class CVATImageTag(CVATImageAnno):
+    """A tag in CVAT image format.
+
+    Args:
+        label: the tag string
+    """
+
+    def __init__(self, label):
+        self.label = label
+        CVATImageAnno.__init__(self)
+
+    def to_classification(self):
+        """Returns a :class:`fiftyone.core.labels.Classification`
+        representation of the tag.
+
+        Returns:
+            a :class:`fiftyone.core.labels.Classification`
+        """
+        return fol.Classification(label=self.label)
+
+    @classmethod
+    def from_classification(cls, classification):
+        """Creates a :class:`CVATImageTag` from a
+        :class:`fiftyone.core.labels.Classification`.
+
+        Args:
+            classification: a :class:`fiftyone.core.labels.Classification`
+
+        Returns:
+            a :class:`CVATImageTag`
+        """
+        label = classification.label
+        return cls(label)
+
+    @classmethod
+    def from_tag_dict(cls, d):
+        """Creates a :class:`CVATImageTag` from a ``<tag>`` tag of a
+        CVAT image annotation XML file.
+
+        Args:
+            d: a dict representation of a ``<tag>`` tag
+
+        Returns:
+            a :class:`CVATImageTag`
+        """
+        label = d["@label"]
+        return cls(label)
+
+
 class CVATImageBox(CVATImageAnno):
     """An object bounding box in CVAT image format.
 


Test patch: