diff --git a/__init__.py b/__init__.py index 6817e94..de5db60 100644 --- a/__init__.py +++ b/__init__.py @@ -555,7 +555,7 @@ def get_parameters(self, ctx, inputs, required_inputs=True): description="A name to assign to the generated project", ) inputs.list( - "member", + "members", self.build_member(required_inputs=required_inputs), default=None, label="Members", @@ -592,11 +592,20 @@ def get_parameters(self, ctx, inputs, required_inputs=True): "or \"NONE\" for no integration)" ) ) + inputs.list( + "required_attrs", + types.String(), + default=None, + label="Required attributes", + description=( + "An optional list of attributes to require on each annotation" + ), + ) def parse_parameters(self, ctx, params): - if "member" in params: - params["member"] = [ - (m["email"], m["role"]) for m in params["member"] + if "members" in params: + params["members"] = [ + (m["email"], m["role"]) for m in params["members"] ] def build_member(self, required_inputs=True): @@ -676,6 +685,14 @@ def execute(self, ctx): def load_annotations(ctx, inputs): + if "custom_labelbox" not in fo.annotation_config.backends: + fo.annotation_config.backends["custom_labelbox"] = {} + + fo.annotation_config.backends["custom_labelbox"].update({ + "config_cls": "custom_labelbox.LabelboxBackendConfig", + "url": "https://labelbox.com" + }) + anno_keys = ctx.dataset.list_annotation_runs() if not anno_keys: @@ -780,7 +797,7 @@ def execute(self, ctx): info = ctx.dataset.get_annotation_info(anno_key) - timestamp = info.timestamp.strftime("%Y-%M-%d %H:%M:%S") + timestamp = info.timestamp.strftime("%Y-%m-%d %H:%M:%S") config = info.config.serialize() config = {k: v for k, v in config.items() if v is not None} @@ -887,6 +904,14 @@ def __call__(self, sample_collection, anno_key, unexpected="prompt", cleanup=Fal foo.execute_operator(self.uri, ctx, params=params) def resolve_input(self, ctx): + if "custom_labelbox" not in fo.annotation_config.backends: + fo.annotation_config.backends["custom_labelbox"] = {} + + fo.annotation_config.backends["custom_labelbox"].update({ + "config_cls": "custom_labelbox.LabelboxBackendConfig", + "url": "https://labelbox.com" + }) + inputs = types.Object() anno_key = get_anno_key(ctx, inputs, show_default=False) diff --git a/custom_labelbox.py b/custom_labelbox.py index 899d4ac..37c5d10 100644 --- a/custom_labelbox.py +++ b/custom_labelbox.py @@ -10,11 +10,13 @@ import logging import os import requests +import urllib.request from uuid import uuid4 import warnings import webbrowser import numpy as np +from PIL import Image import eta.core.image as etai import eta.core.utils as etau @@ -41,6 +43,13 @@ logger = logging.getLogger(__name__) +class LabelboxExportVersion(object): + """Enum for Labelbox export formats and API versions.""" + + V1 = "v1" + V2 = "v2" + + class LabelboxBackendConfig(foua.AnnotationBackendConfig): """Class for configuring :class:`LabelboxBackend` instances. @@ -69,6 +78,10 @@ class LabelboxBackendConfig(foua.AnnotationBackendConfig): iam_integration_name ("DEFAULT"): the name of the IAM integration to associate with the created Labelbox dataset (or "DEFAULT" for the default integration or "NONE" for no integration) + export_version ("v2"): the Labelbox export format and API version to + use. Supported values are ``("v1", "v2")`` + required_attrs (None): a list of attributes that must be specified on + each object """ def __init__( @@ -83,6 +96,8 @@ def __init__( classes_as_attrs=True, upload_media=True, iam_integration_name="DEFAULT", + export_version=LabelboxExportVersion.V2, + required_attrs=None, **kwargs, ): super().__init__(name, label_schema, media_field=media_field, **kwargs) @@ -93,6 +108,8 @@ def __init__( self.classes_as_attrs = classes_as_attrs self.upload_media = upload_media self.iam_integration_name = iam_integration_name + self.export_version = export_version + self.required_attrs = required_attrs if required_attrs else [] # store privately so these aren't serialized self._api_key = api_key @@ -153,7 +170,7 @@ def supported_scalar_types(self): @property def supported_attr_types(self): - return ["text", "select", "radio", "checkbox"] + return ["text", "radio", "checkbox", "select"] @property def supports_keyframes(self): @@ -181,6 +198,7 @@ def _connect_to_api(self): self.config.name, self.config.url, api_key=self.config.api_key, + export_version=self.config.export_version, _experimental=self.config._experimental, ) @@ -223,9 +241,18 @@ class LabelboxAnnotationAPI(foua.AnnotationAPI): name: the name of the backend url: url of the Labelbox server api_key (None): the Labelbox API key + export_version ("v2"): the Labelbox export format and API version to + use. Supported values are ``("v1", "v2")`` """ - def __init__(self, name, url, api_key=None, _experimental=False): + def __init__( + self, + name, + url, + api_key=None, + export_version=LabelboxExportVersion.V2, + _experimental=False, + ): if "://" not in url: protocol = "http" base_url = url @@ -239,6 +266,7 @@ def __init__(self, name, url, api_key=None, _experimental=False): self._experimental = _experimental self._roles = None self._tool_types_map = None + self.export_version = export_version self._setup() @@ -276,6 +304,13 @@ def _setup(self): "scalar": lbo.Classification, } + @property + def _LabelboxExportToFiftyOneConverter(self): + if self.export_version == LabelboxExportVersion.V1: + return _LabelboxExportToFiftyOneConverterV1 + + return _LabelboxExportToFiftyOneConverterV2 + @property def roles(self): if self._roles is None: @@ -287,7 +322,9 @@ def roles(self): def attr_type_map(self): return { "text": lbo.Classification.Type.TEXT, - "select": lbo.Classification.Type.DROPDOWN, + # lbo.Classification.Type.DROPDOWN is deprecated + # select now uses radio + "select": lbo.Classification.Type.RADIO, "radio": lbo.Classification.Type.RADIO, "checkbox": lbo.Classification.Type.CHECKLIST, } @@ -482,18 +519,19 @@ def delete_project( if delete_batches: for batch in project.batches(): batch.delete_labels() - lb.DataRow.bulk_delete( - data_rows=list( - batch.export_data_rows(include_metadata=False) + if self.export_version != LabelboxExportVersion.V1: + lb.DataRow.bulk_delete( + data_rows=list( + batch.export_data_rows(include_metadata=False) + ) ) - ) batch.delete() ontology = project.ontology() project.delete() - if delete_ontologies: + if delete_ontologies and ontology is not None: self._client.delete_unused_ontology(ontology.uid) def delete_projects(self, project_ids, delete_batches=False): @@ -686,6 +724,7 @@ def upload_samples(self, samples, anno_key, backend): members = config.members classes_as_attrs = config.classes_as_attrs iam_integration_name = config.iam_integration_name + required_attrs = config.required_attrs is_video = (samples.media_type == fomm.VIDEO) or ( samples.media_type == fomm.GROUP and samples.group_media_types[samples.group_slice] == fomm.VIDEO @@ -713,7 +752,12 @@ def upload_samples(self, samples, anno_key, backend): global_keys = samples.values("id") project = self._setup_project( - project_name, global_keys, label_schema, classes_as_attrs, is_video + project_name, + global_keys, + label_schema, + classes_as_attrs, + is_video, + required_attrs, ) if members: @@ -734,6 +778,18 @@ def upload_samples(self, samples, anno_key, backend): backend=backend, ) + def get_data_row_ids(self, sample_ids): + try: + data_row_id_response = ( + self._client.get_data_row_ids_for_global_keys(sample_ids) + ) + data_row_ids = data_row_id_response["results"] + except lb.exceptions.TimeoutError: + logger.warning("Request to get data row ids timed out") + data_row_ids = [""] * len(sample_ids) + + return dict(zip(*[sample_ids, data_row_ids])) + def download_annotations(self, results): """Downloads the annotations from the Labelbox server for the given results instance and parses them into the appropriate FiftyOne types. @@ -765,9 +821,11 @@ def download_annotations(self, results): else: class_attr = False + converter = self._LabelboxExportToFiftyOneConverter + for d in labels_json: - labelbox_id = d["DataRow ID"] - sample_id = d["Global Key"] + labelbox_id = converter._get_datarow_id(d) + sample_id = converter._get_global_key(d) if sample_id is None: logger.warning( @@ -787,13 +845,16 @@ def download_annotations(self, results): frame_size = (metadata["width"], metadata["height"]) if is_video: - video_d_list = self._get_video_labels(d["Label"]) frames = {} - for label_d in video_d_list: - frame_number = int(label_d["frameNumber"]) + for frame_number, label_d in converter._iter_video_labels( + d, project_id + ): frame_id = frame_id_map[sample_id][frame_number] - labels_dict = _parse_image_labels( - label_d, frame_size, class_attr=class_attr + labels_dict = converter._parse_image_labels( + label_d, + frame_size, + class_attr=class_attr, + headers=self._client.headers, ) if not classes_as_attrs: labels_dict = self._process_label_fields( @@ -808,8 +869,11 @@ def download_annotations(self, results): label_schema, ) - labels_dict = _parse_image_labels( - d["Label"], frame_size, class_attr=class_attr + labels_dict = converter._parse_image_labels( + converter._get_labels_dict(d, project_id), + frame_size, + class_attr=class_attr, + headers=self._client.headers, ) if not classes_as_attrs: labels_dict = self._process_label_fields( @@ -876,6 +940,7 @@ def _setup_project( label_schema, classes_as_attrs, is_video, + required_attrs, ): media_type = lb.MediaType.Video if is_video else lb.MediaType.Image project = self._client.create_project( @@ -887,7 +952,9 @@ def _setup_project( global_keys=global_keys, ) - self._setup_editor(project, label_schema, classes_as_attrs) + self._setup_editor( + project, label_schema, classes_as_attrs, required_attrs + ) if project.setup_complete is None: raise ValueError( @@ -896,7 +963,9 @@ def _setup_project( return project - def _setup_editor(self, project, label_schema, classes_as_attrs): + def _setup_editor( + self, project, label_schema, classes_as_attrs, required_attrs + ): editor = next( self._client.get_labeling_frontends( where=lb.LabelingFrontend.name == "Editor" @@ -926,7 +995,7 @@ def _setup_editor(self, project, label_schema, classes_as_attrs): label_types[unique_label_type] = label_field field_tools, field_classifications = self._create_ontology_tools( - label_info, label_field, classes_as_attrs + label_info, label_field, classes_as_attrs, required_attrs ) tools.extend(field_tools) classifications.extend(field_classifications) @@ -937,17 +1006,22 @@ def _setup_editor(self, project, label_schema, classes_as_attrs): project.setup(editor, ontology_builder.asdict()) def _create_ontology_tools( - self, label_info, label_field, classes_as_attrs + self, label_info, label_field, classes_as_attrs, required_attrs ): label_type = label_info["type"] classes = label_info["classes"] attr_schema = label_info["attributes"] - general_attrs = self._build_attributes(attr_schema) + general_attrs = self._build_attributes(attr_schema, required_attrs) if label_type in ["scalar", "classification", "classifications"]: tools = [] classifications = self._build_classifications( - classes, label_field, general_attrs, label_type, label_field + classes, + label_field, + general_attrs, + label_type, + label_field, + required_attrs, ) else: tools = self._build_tools( @@ -956,20 +1030,27 @@ def _create_ontology_tools( label_type, general_attrs, classes_as_attrs, + required_attrs, ) classifications = [] return tools, classifications - def _build_attributes(self, attr_schema): + def _build_attributes(self, attr_schema, required_attrs): attributes = [] for attr_name, attr_info in attr_schema.items(): attr_type = attr_info["type"] class_type = self.attr_type_map[attr_type] + if attr_type == "select": + logger.warning( + "The `select` attribute type has been deprecated, using " + "`radio` instead." + ) if attr_type == "text": attr = lbo.Classification( class_type=class_type, name=attr_name, + required=(attr_name in required_attrs), ) else: attr_values = attr_info["values"] @@ -978,6 +1059,7 @@ def _build_attributes(self, attr_schema): class_type=class_type, name=attr_name, options=options, + required=(attr_name in required_attrs), ) attributes.append(attr) @@ -985,7 +1067,13 @@ def _build_attributes(self, attr_schema): return attributes def _build_classifications( - self, classes, name, general_attrs, label_type, label_field + self, + classes, + name, + general_attrs, + label_type, + label_field, + required_attrs, ): """Returns the classifications for the given label field. Generally, the classification is a dropdown selection for given classes, but can @@ -999,7 +1087,9 @@ def _build_classifications( for c in classes: if isinstance(c, dict): sub_classes = c["classes"] - attrs = self._build_attributes(c["attributes"]) + general_attrs + attrs = self._build_attributes( + c["attributes"], required_attrs + ) + general_attrs else: sub_classes = [c] attrs = general_attrs @@ -1046,13 +1136,21 @@ def _build_classifications( return classifications def _build_tools( - self, classes, label_field, label_type, general_attrs, classes_as_attrs + self, + classes, + label_field, + label_type, + general_attrs, + classes_as_attrs, + required_attrs ): tools = [] if classes_as_attrs: tool_type = self._tool_types_map[label_type] - attributes = self._create_classes_as_attrs(classes, general_attrs) + attributes = self._create_classes_as_attrs( + classes, general_attrs, required_attrs + ) tools.append( lbo.Tool( name=label_field, @@ -1065,7 +1163,9 @@ def _build_tools( if isinstance(c, dict): subset_classes = c["classes"] subset_attr_schema = c["attributes"] - subset_attrs = self._build_attributes(subset_attr_schema) + subset_attrs = self._build_attributes( + subset_attr_schema, required_attrs + ) all_attrs = general_attrs + subset_attrs for sc in subset_classes: tool = self._build_tool_for_class( @@ -1088,14 +1188,16 @@ def _build_tool_for_class(self, class_name, label_type, attributes): classifications=attributes, ) - def _create_classes_as_attrs(self, classes, general_attrs): + def _create_classes_as_attrs(self, classes, general_attrs, required_attrs): """Creates radio attributes for all classes and formats all class-specific attributes. """ options = [] for c in classes: if isinstance(c, dict): - subset_attrs = self._build_attributes(c["attributes"]) + subset_attrs = self._build_attributes( + c["attributes"], required_attrs + ) for sc in c["classes"]: options.append( lbo.Option(value=str(sc), options=subset_attrs) @@ -1585,16 +1687,31 @@ def _get_status(self, log=False): @classmethod def _from_dict(cls, d, samples, config, anno_key): + frame_id_map = { + sample_id: { + int(frame_id): frame_data + for frame_id, frame_data in frame_map.items() + } + for sample_id, frame_map in d["frame_id_map"].items() + } return cls( samples, config, anno_key, d["id_map"], d["project_id"], - d["frame_id_map"], + frame_id_map, ) +def _warn_labelbox_v1(): + logger.warning( + "This method uses Labelbox's v1 export format, which is deprecated. " + "Please use the official FiftyOne <> Labelbox integration: " + "https://docs.voxel51.com/integrations/labelbox.html" + ) + + # # @todo # Must add support for populating `schemaId` when exporting @@ -1671,6 +1788,8 @@ def import_from_labelbox( default value ``fiftyone.config.show_progress_bars`` (None), or a progress callback function to invoke instead """ + _warn_labelbox_v1() + fov.validate_collection(dataset, media_type=(fomm.IMAGE, fomm.VIDEO)) is_video = dataset.media_type == fomm.VIDEO @@ -1731,7 +1850,11 @@ def import_from_labelbox( sample.metadata.frame_width, sample.metadata.frame_height, ) - frames = _parse_video_labels(d["Label"], frame_size) + frames = ( + _LabelboxExportToFiftyOneConverterV1._parse_video_labels( + d["Label"], frame_size + ) + ) sample.frames.merge( { frame_number: { @@ -1743,7 +1866,11 @@ def import_from_labelbox( ) else: frame_size = (sample.metadata.width, sample.metadata.height) - labels_dict = _parse_image_labels(d["Label"], frame_size) + labels_dict = ( + _LabelboxExportToFiftyOneConverterV1._parse_image_labels( + d["Label"], frame_size + ) + ) sample.update_fields( {label_key(k): v for k, v in labels_dict.items()} ) @@ -1819,6 +1946,8 @@ def export_to_labelbox( default value ``fiftyone.config.show_progress_bars`` (None), or a progress callback function to invoke instead """ + _warn_labelbox_v1() + fov.validate_collection( sample_collection, media_type=(fomm.IMAGE, fomm.VIDEO) ) @@ -1858,6 +1987,8 @@ def export_to_labelbox( media_fields=list(media_fields.keys()) ) + converter = _FiftyOneToLabelboxExportConverterV1() + # Export the labels annos = [] with fou.ProgressBar(progress=progress) as pb: @@ -1883,7 +2014,7 @@ def export_to_labelbox( # Export sample-level labels if label_fields: labels_dict = _get_labels(sample, label_fields) - sample_annos = _to_labelbox_image_labels( + sample_annos = converter._to_labelbox_image_labels( labels_dict, frame_size, labelbox_id ) annos.extend(sample_annos) @@ -1891,7 +2022,7 @@ def export_to_labelbox( # Export frame-level labels if is_video and frame_label_fields: frames = _get_frame_labels(sample, frame_label_fields) - video_annos = _to_labelbox_video_labels( + video_annos = converter._to_labelbox_video_labels( frames, frame_size, labelbox_id ) @@ -1908,17 +2039,54 @@ def export_to_labelbox( fos.write_ndjson(annos, ndjson_path) -def download_labels_from_labelbox(labelbox_project, outpath=None): +def download_labels_from_labelbox( + labelbox_project, + outpath=None, + export_version=LabelboxExportVersion.V2, +): """Downloads the labels for the given Labelbox project. Args: labelbox_project: a ``labelbox.schema.project.Project`` outpath (None): the path to write the JSON export on disk + export_version ("v2"): the Labelbox export format and API version to + use. Supported values are ``("v1", "v2")`` Returns: ``None`` if an ``outpath`` is provided, or the loaded JSON itself if no ``outpath`` is provided """ + if export_version == LabelboxExportVersion.V1: + return _download_labels_from_labelbox_v1( + labelbox_project, outpath=outpath + ) + + params = { + "data_row_details": True, + "metadata_fields": True, + "attachments": True, + "project_details": True, + "performance_details": True, + "label_details": True, + "interpolated_frames": True, + } + + export_task = labelbox_project.export_v2(params=params) + + export_task.wait_till_done() + if export_task.errors: + logger.warning(export_task.errors) + + export_json = export_task.result + + if outpath: + fos.write_json(export_json, outpath) + return None + + return export_json + + +def _download_labels_from_labelbox_v1(labelbox_project, outpath=None): export_url = labelbox_project.export_labels() if outpath: @@ -2028,6 +2196,8 @@ def convert_labelbox_export_to_import(inpath, outpath=None, video_outdir=None): labels (if applicable). If omitted, the input frame label files will be overwritten """ + _warn_labelbox_v1() + if outpath is None: outpath = inpath @@ -2128,495 +2298,755 @@ def _get_frame_labels(sample, frame_label_fields): return frames -def _to_labelbox_image_labels(labels_dict, frame_size, data_row_id): - annotations = [] - for name, label in labels_dict.items(): - if isinstance(label, (fol.Classification, fol.Classifications)): - anno = _to_global_classification(name, label, data_row_id) - annotations.append(anno) - elif isinstance(label, (fol.Detection, fol.Detections)): - annos = _to_detections(label, frame_size, data_row_id) - annotations.extend(annos) - elif isinstance(label, (fol.Polyline, fol.Polylines)): - annos = _to_polylines(label, frame_size, data_row_id) - annotations.extend(annos) - elif isinstance(label, (fol.Keypoint, fol.Keypoints)): - annos = _to_points(label, frame_size, data_row_id) - annotations.extend(annos) - elif isinstance(label, fol.Segmentation): - annos = _to_mask(name, label, data_row_id) - annotations.extend(annos) - elif label is not None: - msg = "Ignoring unsupported label type '%s'" % label.__class__ - warnings.warn(msg) - - return annotations - - -def _to_labelbox_video_labels(frames, frame_size, data_row_id): - annotations = [] - for frame_number, labels_dict in frames.items(): - frame_annos = _to_labelbox_image_labels( - labels_dict, frame_size, data_row_id - ) - for anno in frame_annos: - anno["frameNumber"] = frame_number - annotations.append(anno) - - return annotations +class _FiftyOneToLabelboxConverterBase(object): - -# https://labelbox.com/docs/exporting-data/export-format-detail#classification -def _to_global_classification(name, label, data_row_id): - anno = _make_base_anno(name, data_row_id=data_row_id) - anno.update(_make_classification_answer(label)) - return anno - - -# https://labelbox.com/docs/exporting-data/export-format-detail#nested_classification -def _get_nested_classifications(label): - classifications = [] - for name, value in label.iter_attributes(): - if etau.is_str(value) or isinstance(value, (list, tuple)): - anno = _make_base_anno(name) - anno.update(_make_classification_answer(value)) - classifications.append(anno) + # https://labelbox.com/docs/exporting-data/export-format-detail#classification + @classmethod + def _to_global_classification(cls, name, label, data_row_id=None): + if isinstance(label, fol.Classification): + label_ids = [label.id] + elif isinstance(label, fol.Classifications): + label_ids = [ + classification.id for classification in label.classifications + ] else: - msg = "Ignoring unsupported attribute type '%s'" % type(value) - warnings.warn(msg) - continue - - return classifications - + label_ids = [] -# https://labelbox.com/docs/automation/model-assisted-labeling#mask_annotations -def _to_mask(name, label, data_row_id): - mask = np.asarray(label.get_mask()) - if mask.ndim < 3 or mask.dtype != np.uint8: - raise ValueError( - "Segmentation masks must be stored as RGB color uint8 images" - ) + anno = cls._make_base_anno(name, data_row_id=data_row_id) + anno.update(cls._make_classification_answer(label)) + return anno, label_ids - try: - instance_uri = label.instance_uri - except: - raise ValueError( - "You must populate the `instance_uri` field of segmentation masks" - ) + @classmethod + def _validate_label_class(cls, label): + return True - # Get unique colors - colors = np.unique(np.reshape(mask, (-1, 3)), axis=0).tolist() + # https://labelbox.com/docs/exporting-data/export-format-detail#nested_classification + def _get_nested_classifications(self, label): + classifications = [] + for name, value in label.iter_attributes(): + if etau.is_str(value) or isinstance(value, (list, tuple)): + anno = self._make_base_anno(name) + anno.update(self._make_classification_answer(value)) + classifications.append(anno) + else: + msg = "Ignoring unsupported attribute type '%s'" % type(value) + warnings.warn(msg) + continue - annos = [] - base_anno = _make_base_anno(name, data_row_id=data_row_id) - for color in colors: - anno = copy(base_anno) - anno["mask"] = _make_mask(instance_uri, color) - annos.append(anno) + return classifications - return annos + # https://labelbox.com/docs/automation/model-assisted-labeling#mask_annotations + @classmethod + def _to_mask(cls, name, label, data_row_id=None): + mask = np.asarray(label.get_mask()) + if mask.ndim < 3 or mask.dtype != np.uint8: + raise ValueError( + "Segmentation masks must be stored as RGB color uint8 images" + ) + try: + instance_uri = label.instance_uri + except: + raise ValueError( + "You must populate the `instance_uri` field of segmentation masks" + ) -# https://labelbox.com/docs/exporting-data/export-format-detail#bounding_boxes -def _to_detections(label, frame_size, data_row_id): - if isinstance(label, fol.Detections): - detections = label.detections - else: - detections = [label] + # Get unique colors + colors = np.unique(np.reshape(mask, (-1, 3)), axis=0).tolist() - annos = [] - for detection in detections: - anno = _make_base_anno(detection.label, data_row_id=data_row_id) - anno["bbox"] = _make_bbox(detection.bounding_box, frame_size) + annos = [] + base_anno = cls._make_base_anno(name, data_row_id=data_row_id) + for color in colors: + anno = copy(base_anno) + anno["mask"] = cls._make_mask(instance_uri, color) + annos.append(anno) - classifications = _get_nested_classifications(detection) - if classifications: - anno["classifications"] = classifications + return annos - annos.append(anno) + @classmethod + def _get_base_anno_name(cls, label): + return label.label - return annos + # https://labelbox.com/docs/exporting-data/export-format-detail#bounding_boxes + def _to_detections(self, label, frame_size, data_row_id=None): + if isinstance(label, fol.Detections): + detections = label.detections + else: + detections = [label] + annos = [] + label_ids = [] + for detection in detections: + if not self._validate_label_class(detection): + continue -# https://labelbox.com/docs/exporting-data/export-format-detail#polygons -# https://labelbox.com/docs/exporting-data/export-format-detail#polylines -def _to_polylines(label, frame_size, data_row_id): - if isinstance(label, fol.Polylines): - polylines = label.polylines - else: - polylines = [label] + anno_name = self._get_base_anno_name(detection) + anno = self._make_base_anno(anno_name, data_row_id=data_row_id) + anno["bbox"] = self._make_bbox(detection.bounding_box, frame_size) - annos = [] - for polyline in polylines: - field = "polygon" if polyline.filled else "line" - classifications = _get_nested_classifications(polyline) - for points in polyline.points: - anno = _make_base_anno(polyline.label, data_row_id=data_row_id) - anno[field] = [_make_point(point, frame_size) for point in points] + classifications = self._get_nested_classifications(detection) if classifications: anno["classifications"] = classifications annos.append(anno) + label_ids.append(detection.id) - return annos - - -# https://labelbox.com/docs/exporting-data/export-format-detail#points -def _to_points(label, frame_size, data_row_id): - if isinstance(label, fol.Keypoints): - keypoints = label.keypoints - else: - keypoints = [keypoints] - - annos = [] - for keypoint in keypoints: - classifications = _get_nested_classifications(keypoint) - for point in keypoint.points: - anno = _make_base_anno(keypoint.label, data_row_id=data_row_id) - anno["point"] = _make_point(point, frame_size) - if classifications: - anno["classifications"] = classifications - - annos.append(anno) + return annos, label_ids - return annos + # https://labelbox.com/docs/exporting-data/export-format-detail#polygons + # https://labelbox.com/docs/exporting-data/export-format-detail#polylines + def _to_polylines(self, label, frame_size, data_row_id=None): + if isinstance(label, fol.Polylines): + polylines = label.polylines + else: + polylines = [label] + annos = [] + label_ids = [] + for polyline in polylines: + if not self._validate_label_class(polyline): + continue + field = "polygon" if polyline.filled else "line" + classifications = self._get_nested_classifications(polyline) + for points in polyline.points: + anno_name = self._get_base_anno_name(polyline) + anno = self._make_base_anno(anno_name, data_row_id=data_row_id) + anno[field] = [ + self._make_point(point, frame_size) for point in points + ] + if classifications: + anno["classifications"] = classifications -def _make_base_anno(value, data_row_id=None): - anno = { - "uuid": str(uuid4()), - "schemaId": None, - "title": value, - "value": value, - } + annos.append(anno) + label_ids.append(polyline.id) - if data_row_id: - anno["dataRow"] = {"id": data_row_id} + return annos, label_ids - return anno + # https://labelbox.com/docs/exporting-data/export-format-detail#points + def _to_points(self, label, frame_size, data_row_id=None): + if isinstance(label, fol.Keypoints): + keypoints = label.keypoints + else: + keypoints = [keypoints] + annos = [] + label_ids = [] + for keypoint in keypoints: + if not self._validate_label_class(keypoint): + continue + classifications = self._get_nested_classifications(keypoint) + for point in keypoint.points: + anno_name = self._get_base_anno_name(keypoint) + anno = self._make_base_anno(anno_name, data_row_id=data_row_id) + anno["point"] = self._make_point(point, frame_size) + if classifications: + anno["classifications"] = classifications -def _make_video_anno(labels_path, data_row_id=None): - anno = { - "uuid": str(uuid4()), - "frames": labels_path, - } + annos.append(anno) + label_ids.append(keypoint.id) - if data_row_id: - anno["dataRow"] = {"id": data_row_id} + return annos, label_ids - return anno + @classmethod + def _make_base_anno(cls, value, data_row_id=None): + anno = { + "uuid": str(uuid4()), + "schemaId": None, + "title": value, + "value": value, + } + if data_row_id: + anno["dataRow"] = {"id": data_row_id} -def _make_classification_answer(label): - if isinstance(label, fol.Classification): - # Assume free text - return {"answer": label.label} + return anno - if isinstance(label, fol.Classifications): - # Assume checklist - return {"answers": [{"value": c.label} for c in label.classifications]} + @classmethod + def _make_classification_answer(cls, label): + if isinstance(label, fol.Classification): + # Assume free text + return {"answer": label.label} + + if isinstance(label, fol.Classifications): + # Assume checklist + return { + "answers": [{"value": c.label} for c in label.classifications] + } - if etau.is_str(label): - # Assume free text - return {"answer": label} + if etau.is_str(label): + # Assume free text + return {"answer": label} - if isinstance(label, (list, tuple)): - # Assume checklist - return {"answers": [{"value": value} for value in label]} + if isinstance(label, (list, tuple)): + # Assume checklist + return {"answers": [{"value": value} for value in label]} - raise ValueError("Cannot convert %s to a classification" % label.__class__) + raise ValueError( + "Cannot convert %s to a classification" % label.__class__ + ) + @classmethod + def _make_bbox(cls, bounding_box, frame_size): + x, y, w, h = bounding_box + width, height = frame_size + return { + "left": round(x * width, 1), + "top": round(y * height, 1), + "width": round(w * width, 1), + "height": round(h * height, 1), + } -def _make_bbox(bounding_box, frame_size): - x, y, w, h = bounding_box - width, height = frame_size - return { - "left": round(x * width, 1), - "top": round(y * height, 1), - "width": round(w * width, 1), - "height": round(h * height, 1), - } + @classmethod + def _make_point(cls, point, frame_size): + x, y = point + width, height = frame_size + return {"x": round(x * width, 1), "y": round(y * height, 1)} + @classmethod + def _make_mask(cls, instance_uri, color): + return { + "instanceURI": instance_uri, + "colorRGB": list(color), + } -def _make_point(point, frame_size): - x, y = point - width, height = frame_size - return {"x": round(x * width, 1), "y": round(y * height, 1)} +class _FiftyOneToLabelboxExportConverterV1(_FiftyOneToLabelboxConverterBase): + # Converts FiftyOne labels to Labelbox export format v1: + # https://docs.labelbox.com/reference/export-image-annotations + def _to_labelbox_image_labels(self, labels_dict, frame_size, data_row_id): + annotations = [] + for name, label in labels_dict.items(): + if isinstance(label, (fol.Classification, fol.Classifications)): + anno, label_ids = self._to_global_classification( + name, label, data_row_id=data_row_id + ) + annotations.append(anno) + elif isinstance(label, (fol.Detection, fol.Detections)): + annos, label_ids = self._to_detections( + label, frame_size, data_row_id=data_row_id + ) + annotations.extend(annos) + elif isinstance(label, (fol.Polyline, fol.Polylines)): + annos, label_ids = self._to_polylines( + label, frame_size, data_row_id=data_row_id + ) + annotations.extend(annos) + elif isinstance(label, (fol.Keypoint, fol.Keypoints)): + annos, label_ids = self._to_points( + label, frame_size, data_row_id=data_row_id + ) + annotations.extend(annos) + elif isinstance(label, fol.Segmentation): + label_ids = [label.id] + annos = self._to_mask(name, label, data_row_id=data_row_id) + annotations.extend(annos) + elif label is not None: + msg = "Ignoring unsupported label type '%s'" % label.__class__ + warnings.warn(msg) -def _make_mask(instance_uri, color): - return { - "instanceURI": instance_uri, - "colorRGB": list(color), - } + return annotations + def _to_labelbox_video_labels(self, frames, frame_size, data_row_id): + annotations = [] + for frame_number, labels_dict in frames.items(): + frame_annos = self._to_labelbox_image_labels( + labels_dict, frame_size, data_row_id + ) + for anno in frame_annos: + anno["frameNumber"] = frame_number + annotations.append(anno) -# Parse v1 export format -# https://docs.labelbox.com/reference/export-video-annotations -def _parse_video_labels(video_label_d, frame_size): - url_or_filepath = video_label_d["frames"] - label_d_list = fos.read_ndjson(url_or_filepath) + return annotations - frames = {} - for label_d in label_d_list: - frame_number = label_d["frameNumber"] - frames[frame_number] = _parse_image_labels(label_d, frame_size) - return frames +class _LabelboxExportToFiftyOneConverterV1(object): + # Parse v1 export format + # https://docs.labelbox.com/reference/export-video-annotations + @classmethod + def _parse_video_labels(cls, video_label_d, frame_size): + url_or_filepath = video_label_d["frames"] + label_d_list = fos.read_ndjson(url_or_filepath) -# Parse v1 export format -# https://docs.labelbox.com/reference/export-image-annotations#annotation-export-formats -def _parse_image_labels(label_d, frame_size, class_attr=None): - labels = {} + frames = {} + for label_d in label_d_list: + frame_number = label_d["frameNumber"] + frames[frame_number] = cls._parse_image_labels(label_d, frame_size) - # Parse classifications - cd_list = label_d.get("classifications", []) + return frames - classifications = _parse_classifications(cd_list) - labels.update(classifications) + # Parse v1 export format + # https://docs.labelbox.com/reference/export-image-annotations#annotation-export-formats + @classmethod + def _parse_image_labels( + cls, label_d, frame_size, class_attr=None, headers=None + ): + labels = {} - # Parse objects - # @todo what if `objects.keys()` conflicts with `classifications.keys()`? - od_list = label_d.get("objects", []) - objects = _parse_objects(od_list, frame_size, class_attr=class_attr) - labels.update(objects) + # Parse classifications + cd_list = label_d.get("classifications", []) - return labels + classifications = cls._parse_classifications(cd_list) + labels.update(classifications) + # Parse objects + # @todo what if `objects.keys()` conflicts with `classifications.keys()`? + od_list = label_d.get("objects", []) + objects = cls._parse_objects( + od_list, frame_size, class_attr=class_attr, headers=headers + ) + labels.update(objects) -def _parse_classifications(cd_list): - labels = {} + return labels - for cd in cd_list: - name = cd["value"] - if "answer" in cd: - answer = cd["answer"] - if isinstance(answer, list): - # Dropdown - labels[name] = fol.Classifications( - classifications=[ - fol.Classification(label=a["value"]) for a in answer - ] - ) - elif isinstance(answer, dict): - # Radio question - labels[name] = fol.Classification(label=answer["value"]) - else: - # Free text - labels[name] = fol.Classification(label=answer) + @classmethod + def _get_datarow_id(cls, d): + return d["DataRow ID"] - if "answers" in cd: - # Checklist - answers = cd["answers"] - labels[name] = fol.Classifications( - classifications=[ - fol.Classification(label=a["value"]) for a in answers - ] - ) + @classmethod + def _get_global_key(cls, d): + return d["Global Key"] - return labels + @classmethod + def _get_answer_value(cls, answer): + return answer["value"] + @classmethod + def _iter_video_labels(cls, d, project_id): + for label_d in cls._get_labels_dict(d, project_id): + yield int(label_d["frameNumber"]), label_d -def _parse_attributes(cd_list): - attributes = {} + @classmethod + def _get_labels_dict(cls, d, project_id): + return d["Label"] - for cd in cd_list: - if isinstance(cd, list): - attributes.update(_parse_attributes(cd)) + @classmethod + def _parse_classifications(cls, cd_list): + labels = {} - else: + for cd in cd_list: name = cd["value"] if "answer" in cd: answer = cd["answer"] if isinstance(answer, list): - # Dropdown - answers = [_parse_attribute(a["value"]) for a in answer] - if len(answers) == 1: - answers = answers[0] - - attributes[name] = answers - - elif isinstance(answer, dict): - # Radio question - attributes[name] = _parse_attribute(answer["value"]) + cd["answers"] = answer else: - # Free text - attributes[name] = _parse_attribute(answer) + if isinstance(answer, list): + # Dropdown + labels[name] = fol.Classifications( + classifications=[ + fol.Classification( + label=cls._get_answer_value(a) + ) + for a in answer + ] + ) + elif isinstance(answer, dict): + # Radio question + labels[name] = fol.Classification( + label=cls._get_answer_value(answer) + ) + else: + # Free text + labels[name] = fol.Classification(label=answer) if "answers" in cd: # Checklist - answer = cd["answers"] - attributes[name] = [ - _parse_attribute(a["value"]) for a in answer - ] + answers = cd["answers"] + labels[name] = fol.Classifications( + classifications=[ + fol.Classification(label=cls._get_answer_value(a)) + for a in answers + ] + ) - return attributes + return labels + @classmethod + def _parse_attributes(cls, cd_list): + attributes = {} + + for cd in cd_list: + if isinstance(cd, list): + attributes.update(cls._parse_attributes(cd)) -def _parse_objects(od_list, frame_size, class_attr=None): - detections = [] - polylines = [] - keypoints = [] - segmentations = [] - mask = None - mask_instance_uri = None - label_fields = {} - for od in od_list: - attributes = _parse_attributes(od.get("classifications", [])) - load_fo_seg = class_attr is not None - if class_attr and class_attr in attributes: - label_field = od["title"] - label = attributes.pop(class_attr) - if label_field not in label_fields: - label_fields[label_field] = {} - else: - label = od["value"] - label_field = None - - if "bbox" in od: - # Detection - bounding_box = _parse_bbox(od["bbox"], frame_size) - det = fol.Detection( - label=label, bounding_box=bounding_box, **attributes - ) - if label_field is None: - detections.append(det) - else: - if "detections" not in label_fields[label_field]: - label_fields[label_field]["detections"] = [] - - label_fields[label_field]["detections"].append(det) - - elif "polygon" in od: - # Polyline - points = _parse_points(od["polygon"], frame_size) - polyline = fol.Polyline( - label=label, - points=[points], - closed=True, - filled=True, - **attributes, - ) - if label_field is None: - polylines.append(polyline) - else: - if "polylines" not in label_fields[label_field]: - label_fields[label_field]["polylines"] = [] - - label_fields[label_field]["polylines"].append(polyline) - - elif "line" in od: - # Polyline - points = _parse_points(od["line"], frame_size) - polyline = fol.Polyline( - label=label, - points=[points], - closed=True, - filled=False, - **attributes, - ) - if label_field is None: - polylines.append(polyline) else: - if "polylines" not in label_fields[label_field]: - label_fields[label_field]["polylines"] = [] + name = cls._get_answer_value(cd) + if "answer" in cd: + answer = cd["answer"] + if isinstance(answer, list): + # Dropdown + answers = [ + _parse_attribute(cls._get_answer_value(a)) + for a in answer + ] + if len(answers) == 1: + answers = answers[0] + + attributes[name] = answers + + elif isinstance(answer, dict): + # Radio question + attributes[name] = _parse_attribute( + cls._get_answer_value(answer) + ) + else: + # Free text + attributes[name] = _parse_attribute(answer) + + if "answers" in cd: + # Checklist + answer = cd["answers"] + attributes[name] = [ + _parse_attribute(cls._get_answer_value(a)) + for a in answer + ] + + return attributes + + @classmethod + def _bounding_box_name(cls): + return "bbox" - label_fields[label_field]["polylines"].append(polyline) + @classmethod + def _get_mask_url(cls, od): + return od["instanceURI"] - elif "point" in od: - # Keypoint - point = _parse_point(od["point"], frame_size) - keypoint = fol.Keypoint(label=label, points=[point], **attributes) - if label_field is None: - keypoints.append(keypoint) + @classmethod + def _get_label_field_attr(cls, od): + return od["title"] + + @classmethod + def _parse_objects( + cls, od_list, frame_size, class_attr=None, headers=None + ): + detections = [] + polylines = [] + keypoints = [] + segmentations = [] + mask = None + mask_instance_uri = None + load_fo_seg = class_attr is not None + label_fields = {} + for od in od_list: + attributes = cls._parse_attributes(od.get("classifications", [])) + if class_attr and class_attr in attributes: + label_field = cls._get_label_field_attr(od) + label = attributes.pop(class_attr) + if label_field not in label_fields: + label_fields[label_field] = {} else: - if "keypoints" not in label_fields[label_field]: - label_fields[label_field]["keypoints"] = [] + label = od["value"] + label_field = None - label_fields[label_field]["keypoints"].append(keypoint) + if cls._bounding_box_name() in od: + # Detection + bounding_box = cls._parse_bbox( + od[cls._bounding_box_name()], frame_size + ) + det = fol.Detection( + label=label, bounding_box=bounding_box, **attributes + ) + if label_field is None: + detections.append(det) + else: + if "detections" not in label_fields[label_field]: + label_fields[label_field]["detections"] = [] + + label_fields[label_field]["detections"].append(det) + + elif "polygon" in od: + # Polyline + points = cls._parse_points(od["polygon"], frame_size) + polyline = fol.Polyline( + label=label, + points=[points], + closed=True, + filled=True, + **attributes, + ) + if label_field is None: + polylines.append(polyline) + else: + if "polylines" not in label_fields[label_field]: + label_fields[label_field]["polylines"] = [] + + label_fields[label_field]["polylines"].append(polyline) + + elif "line" in od: + # Polyline + points = cls._parse_points(od["line"], frame_size) + polyline = fol.Polyline( + label=label, + points=[points], + closed=False, + filled=False, + **attributes, + ) + if label_field is None: + polylines.append(polyline) + else: + if "polylines" not in label_fields[label_field]: + label_fields[label_field]["polylines"] = [] + + label_fields[label_field]["polylines"].append(polyline) - elif "instanceURI" in od: - # Segmentation mask - if not load_fo_seg: - if mask is None: - mask_instance_uri = od["instanceURI"] - mask = _parse_mask(mask_instance_uri) + elif "point" in od: + # Keypoint + point = cls._parse_point(od["point"], frame_size) + keypoint = fol.Keypoint( + label=label, points=[point], **attributes + ) + if label_field is None: + keypoints.append(keypoint) + else: + if "keypoints" not in label_fields[label_field]: + label_fields[label_field]["keypoints"] = [] + + label_fields[label_field]["keypoints"].append(keypoint) + + elif "instanceURI" in od or "mask" in od: + # Segmentation mask + if not load_fo_seg: + # This condition is only triggered by the deprecated + # `import_from_labelbox()` method + if mask is None: + mask_instance_uri = cls._get_mask_url(od) + mask = cls._parse_mask( + mask_instance_uri, headers=headers + ) + segmentation = { + "mask": mask, + "label": label, + "attributes": attributes, + } + elif cls._get_mask_url(od) != mask_instance_uri: + msg = ( + "Only one segmentation mask per image/frame is " + "allowed; skipping additional mask(s)" + ) + warnings.warn(msg) + else: + # Segmentations are later loaded as either fo.Segmentation + # or fo.Detection instances once the label schema of the + # annotation task is available + current_mask_instance_uri = cls._get_mask_url(od) + current_mask = cls._parse_mask( + current_mask_instance_uri, headers=headers + ) segmentation = { "mask": current_mask, "label": label, "attributes": attributes, } - elif od["instanceURI"] != mask_instance_uri: - msg = ( - "Only one segmentation mask per image/frame is " - "allowed; skipping additional mask(s)" - ) - warnings.warn(msg) + if label_field is not None: + if "segmentation" not in label_fields[label_field]: + label_fields[label_field]["segmentation"] = [] + + label_fields[label_field]["segmentation"].append( + segmentation + ) + else: + segmentations.append(segmentation) else: - current_mask_instance_uri = od["instanceURI"] - current_mask = _parse_mask(current_mask_instance_uri) - segmentation = { - "mask": current_mask, - "label": label, - "attributes": attributes, - } - if label_field is not None: - if "segmentation" not in label_fields[label_field]: - label_fields[label_field]["segmentation"] = [] + msg = "Ignoring unsupported label" + warnings.warn(msg) - label_fields[label_field]["segmentation"].append( - segmentation - ) - else: - segmentations.append(segmentation) - else: - msg = "Ignoring unsupported label" - warnings.warn(msg) + labels = {} + + if detections: + labels["detections"] = fol.Detections(detections=detections) + + if polylines: + labels["polylines"] = fol.Polylines(polylines=polylines) + + if keypoints: + labels["keypoints"] = fol.Keypoints(keypoints=keypoints) + + if mask is not None: + labels["segmentation"] = mask + elif segmentations: + labels["segmentation"] = segmentations - labels = {} + labels.update(label_fields) - if detections: - labels["detections"] = fol.Detections(detections=detections) + return labels + + @classmethod + def _parse_bbox(cls, bd, frame_size): + width, height = frame_size + x = bd["left"] / width + y = bd["top"] / height + w = bd["width"] / width + h = bd["height"] / height + return [x, y, w, h] + + @classmethod + def _parse_points(cls, pd_list, frame_size): + return [cls._parse_point(pd, frame_size) for pd in pd_list] - if polylines: - labels["polylines"] = fol.Polylines(polylines=polylines) + @classmethod + def _parse_point(cls, pd, frame_size): + width, height = frame_size + return (pd["x"] / width, pd["y"] / height) - if keypoints: - labels["keypoints"] = fol.Keypoints(keypoints=keypoints) + @classmethod + def _parse_mask(cls, instance_uri, headers=None): + img_bytes = fos.read_file(instance_uri, "rb") + return etai.decode(img_bytes) - if mask is not None: - labels["segmentation"] = mask - elif segmentations: - labels["segmentation"] = segmentations - labels.update(label_fields) +class _LabelboxExportToFiftyOneConverterV2( + _LabelboxExportToFiftyOneConverterV1 +): + @classmethod + def _get_answer_value(cls, answer): + return answer["name"] - return labels + @classmethod + def _get_datarow_id(cls, d): + return d["data_row"]["id"] + @classmethod + def _get_global_key(cls, d): + return d["data_row"]["global_key"] -def _parse_bbox(bd, frame_size): - width, height = frame_size - x = bd["left"] / width - y = bd["top"] / height - w = bd["width"] / width - h = bd["height"] / height - return [x, y, w, h] + @classmethod + def _iter_video_labels(cls, d, project_id): + frames = {} + for annos in d["projects"][project_id]["labels"]: + for frame_number, frame_values in annos["annotations"][ + "frames" + ].items(): + if frame_number not in frames: + frames[frame_number] = { + "objects": [], + "classifications": [], + "relationships": [], + } + for key, values in frame_values.items(): + # https://docs.labelbox.com/reference/export-video-annotations#sample-project-export + # Iterate through objects, classifications, relationships + if isinstance(values, dict): + # For videos, objects are dicts, get list of values + values = list(values.values()) + frames[frame_number][key].extend(values) -def _parse_points(pd_list, frame_size): - return [_parse_point(pd, frame_size) for pd in pd_list] + for frame_number, label_d in frames.items(): + yield int(frame_number), label_d + @classmethod + def _get_labels_dict(cls, d, project_id): + annotations = { + "objects": [], + "classifications": [], + "relationships": [], + } + for labels_dict in d["projects"][project_id]["labels"]: + for key, value in labels_dict["annotations"].items(): + if key in annotations: + annotations[key].extend(value) -def _parse_point(pd, frame_size): - width, height = frame_size - return (pd["x"] / width, pd["y"] / height) + return annotations + @classmethod + def _parse_classifications(cls, cd_list): + labels = {} -def _parse_mask(instance_uri): - img_bytes = fos.read_file(instance_uri, "rb") - return etai.decode(img_bytes) + for cd in cd_list: + name = cd["name"] + if "radio_answer" in cd: + # Radio question + answer = cd["radio_answer"] + attributes = cls._parse_attributes(answer["classifications"]) + attributes.pop("label", None) + labels[name] = fol.Classification( + label=cls._get_answer_value(answer), **attributes + ) + elif "checklist_answers" in cd: + # Checklist + answers = cd["checklist_answers"] + classifications = [] + for a in answers: + attributes = cls._parse_attributes(a["classifications"]) + attributes.pop("label", None) + classifications.append( + fol.Classification( + label=cls._get_answer_value(a), + **attributes, + ) + ) + labels[name] = fol.Classifications( + classifications=classifications + ) + elif "text_answer" in cd: + # Free text + answer = cd["text_answer"].get("content", None) + labels[name] = fol.Classification(label=answer) + + return labels + + @classmethod + def _parse_attributes(cls, cd_list): + attributes = {} + + for cd in cd_list: + if "classifications" in cd and cd["classifications"]: + attributes.update(cls._parse_attributes(cd["classifications"])) + + else: + name = cls._get_answer_value(cd) + if "radio_answer" in cd: + # Radio question + answer = cd["radio_answer"] + attributes[name] = _parse_attribute( + cls._get_answer_value(answer) + ) + elif "checklist_answers" in cd: + # Checklist + answers = cd["checklist_answers"] + attributes[name] = [ + _parse_attribute(cls._get_answer_value(a)) + for a in answers + ] + + elif "text_answer" in cd: + # Free text + answer = cd["text_answer"].get("content", None) + attributes[name] = _parse_attribute(answer) + + return attributes + + @classmethod + def _bounding_box_name(cls): + return "bounding_box" + + @classmethod + def _get_mask_url(cls, od): + return od["mask"]["url"] + + @classmethod + def _get_label_field_attr(cls, od): + return od["name"] + + @classmethod + def _parse_mask(cls, instance_uri, headers=None): + req = urllib.request.Request(instance_uri, headers=headers) + open_url = urllib.request.urlopen(req) + return np.array(Image.open(open_url)) + + +def _make_video_anno(labels_path, data_row_id=None): + anno = { + "uuid": str(uuid4()), + "frames": labels_path, + } + + if data_row_id: + anno["dataRow"] = {"id": data_row_id} + + return anno def _parse_attribute(value):