diff --git a/roboflow/adapters/rfapi.py b/roboflow/adapters/rfapi.py index 1a5364a7..3e1d1982 100644 --- a/roboflow/adapters/rfapi.py +++ b/roboflow/adapters/rfapi.py @@ -14,8 +14,19 @@ class RoboflowError(Exception): pass -class UploadError(RoboflowError): - pass +class ImageUploadError(RoboflowError): + def __init__(self, message, status_code=None): + self.message = message + self.status_code = status_code + self.retries = 0 + super().__init__(self.message) + + +class AnnotationSaveError(RoboflowError): + def __init__(self, message, status_code=None): + self.message = message + self.status_code = status_code + super().__init__(self.message) def get_workspace(api_key, workspace_url): @@ -78,24 +89,38 @@ def upload_image( else: # Hosted image upload url - upload_url = _hosted_upload_url(api_key, project_url, image_path, split, coalesced_batch_name, tag_names) + # Get response response = requests.post(upload_url, timeout=(300, 300)) + responsejson = None try: responsejson = response.json() except Exception: pass + if response.status_code != 200: if responsejson: - raise UploadError(f"Bad response: {response.status_code}: {responsejson}") + err_msg = responsejson + + if err_msg.get("error"): + err_msg = err_msg["error"] + + if err_msg.get("message"): + err_msg = err_msg["message"] + + raise ImageUploadError(err_msg, status_code=response.status_code) else: - raise UploadError(f"Bad response: {response}") + raise ImageUploadError(str(response), status_code=response.status_code) + if not responsejson: # fail fast - raise UploadError(f"upload image {image_path} 200 OK, unexpected response: {response}") + raise ImageUploadError(str(response), status_code=response.status_code) + if not (responsejson.get("success") or responsejson.get("duplicate")): - raise UploadError(f"Server rejected image: {responsejson}") + message = responsejson.get("message") or str(responsejson) + raise ImageUploadError(message) + return responsejson @@ -128,24 +153,28 @@ def save_annotation( headers={"Content-Type": "application/json"}, timeout=(60, 60), ) + + # Handle response responsejson = None try: responsejson = response.json() except Exception: pass + if not responsejson: - raise _save_annotation_error(image_id, response) + raise _save_annotation_error(response) if response.status_code not in (200, 409): - raise _save_annotation_error(image_id, response) + raise _save_annotation_error(response) if response.status_code == 409: if "already annotated" in responsejson.get("error", {}).get("message"): return {"warn": "already annotated"} else: - raise _save_annotation_error(image_id, response) + raise _save_annotation_error(response) if responsejson.get("error"): - raise _save_annotation_error(image_id, response) + raise _save_annotation_error(response) if not responsejson.get("success"): - raise _save_annotation_error(image_id, response) + raise _save_annotation_error(response) + return responsejson @@ -191,17 +220,20 @@ def _local_upload_url(api_key, project_url, batch_name, tag_names, sequence_numb return _upload_url(api_key, project_url, **query_params) -def _save_annotation_error(image_id, response): - errmsg = f"save annotation for {image_id} / " +def _save_annotation_error(response): responsejson = None try: responsejson = response.json() except Exception: pass + if not responsejson: - errmsg += f"bad response: {response.status_code}: {response}" - elif responsejson.get("error"): - errmsg += f"bad response: {response.status_code}: {responsejson['error']}" - else: - errmsg += f"bad response: {response.status_code}: {responsejson}" - return UploadError(errmsg) + return AnnotationSaveError(response, status_code=response.status_code) + + if responsejson.get("error"): + err_msg = responsejson["error"] + if err_msg.get("message"): + err_msg = err_msg["message"] + return AnnotationSaveError(err_msg, status_code=response.status_code) + + return AnnotationSaveError(str(responsejson), status_code=response.status_code) diff --git a/roboflow/core/project.py b/roboflow/core/project.py index 35e1fe5b..1448d205 100644 --- a/roboflow/core/project.py +++ b/roboflow/core/project.py @@ -2,7 +2,6 @@ import json import mimetypes import os -import re import sys import time import warnings @@ -12,6 +11,7 @@ import requests from roboflow.adapters import rfapi +from roboflow.adapters.rfapi import ImageUploadError from roboflow.config import API_URL, DEMO_KEYS from roboflow.core.version import Version from roboflow.util.general import Retry @@ -465,6 +465,76 @@ def upload( print("[ " + path + " ] was skipped.") continue + def upload_image( + self, + image_path=None, + hosted_image=False, + split="train", + num_retry_uploads=0, + batch_name=None, + tag_names=[], + sequence_number=None, + sequence_size=None, + **kwargs, + ): + project_url = self.id.rsplit("/")[1] + + t0 = time.time() + upload_retry_attempts = 0 + retry = Retry(num_retry_uploads, ImageUploadError) + + try: + image = retry( + rfapi.upload_image, + self.__api_key, + project_url, + image_path, + hosted_image=hosted_image, + split=split, + batch_name=batch_name, + tag_names=tag_names, + sequence_number=sequence_number, + sequence_size=sequence_size, + **kwargs, + ) + upload_retry_attempts = retry.retries + except ImageUploadError as e: + e.retries = upload_retry_attempts + raise e + + upload_time = time.time() - t0 + + return image, upload_time, upload_retry_attempts + + def save_annotation( + self, + annotation_path=None, + annotation_labelmap=None, + image_id=None, + job_name=None, + is_prediction: bool = False, + annotation_overwrite=False, + ): + project_url = self.id.rsplit("/")[1] + annotation_name, annotation_str = self._annotation_params(annotation_path) + t0 = time.time() + + annotation = rfapi.save_annotation( + self.__api_key, + project_url, + annotation_name, # type: ignore[type-var] + annotation_str, # type: ignore[type-var] + image_id, + job_name=job_name, # type: ignore[type-var] + is_prediction=is_prediction, + annotation_labelmap=annotation_labelmap, + overwrite=annotation_overwrite, + ) + + upload_time = time.time() - t0 + + return annotation, upload_time + def single_upload( self, image_path=None, @@ -482,64 +552,41 @@ def single_upload( sequence_size=None, **kwargs, ): - project_url = self.id.rsplit("/")[1] if image_path and image_id: raise Exception("You can't pass both image_id and image_path") if not (image_path or image_id): raise Exception("You need to pass image_path or image_id") if isinstance(annotation_labelmap, str): annotation_labelmap = load_labelmap(annotation_labelmap) + uploaded_image, uploaded_annotation = None, None - upload_time = None + upload_time, annotation_time = None, None upload_retry_attempts = 0 + if image_path: - t0 = time.time() - try: - retry = Retry(num_retry_uploads, Exception) - uploaded_image = retry( - rfapi.upload_image, - self.__api_key, - project_url, - image_path, - hosted_image=hosted_image, - split=split, - batch_name=batch_name, - tag_names=tag_names, - sequence_number=sequence_number, - sequence_size=sequence_size, - **kwargs, - ) - image_id = uploaded_image["id"] # type: ignore[index] - upload_retry_attempts = retry.retries - except rfapi.UploadError as e: - raise RuntimeError(f"Error uploading image: {self._parse_upload_error(e)}") - except BaseException as e: - uploaded_image = {"error": e} - finally: - upload_time = time.time() - t0 - - annotation_time = None + uploaded_image, upload_time, upload_retry_attempts = self.upload_image( + image_path, + hosted_image, + split, + num_retry_uploads, + batch_name, + tag_names, + sequence_number, + sequence_size, + **kwargs, + ) + image_id = uploaded_image["id"] # type: ignore[index] + if annotation_path and image_id: - annotation_name, annotation_str = self._annotation_params(annotation_path) - try: - t0 = time.time() - uploaded_annotation = rfapi.save_annotation( - self.__api_key, - project_url, - annotation_name, # type: ignore[type-var] - annotation_str, # type: ignore[type-var] - image_id, - job_name=batch_name, # type: ignore[type-var] - is_prediction=is_prediction, - annotation_labelmap=annotation_labelmap, - overwrite=annotation_overwrite, - ) - except rfapi.UploadError as e: - raise RuntimeError(f"Error uploading annotation: {self._parse_upload_error(e)}") - except BaseException as e: - uploaded_annotation = {"error": e} - finally: - annotation_time = time.time() - t0 + uploaded_annotation, annotation_time = self.save_annotation( + annotation_path, + annotation_labelmap, + image_id, + batch_name, + is_prediction, + annotation_overwrite, + ) + return { "image": uploaded_image, "annotation": uploaded_annotation, @@ -568,20 +615,6 @@ def _annotation_params(self, annotation_path): ) return annotation_name, annotation_string - def _parse_upload_error(self, error: rfapi.UploadError) -> str: - dict_part = str(error).split(": ", 2)[2] - dict_part = dict_part.replace("True", "true") - dict_part = dict_part.replace("False", "false") - dict_part = dict_part.replace("None", "null") - if re.search(r"'\w+':", dict_part): - temp_str = dict_part.replace(r"\'", "") - temp_str = temp_str.replace('"', r"\"") - temp_str = temp_str.replace("'", '"') - dict_part = temp_str.replace("", "'") - parsed_dict: dict = json.loads(dict_part) - message = parsed_dict.get("message") - return message or str(parsed_dict) - def search( self, like_image: Optional[str] = None, diff --git a/roboflow/core/workspace.py b/roboflow/core/workspace.py index b3179e7d..9083c5d8 100644 --- a/roboflow/core/workspace.py +++ b/roboflow/core/workspace.py @@ -9,11 +9,12 @@ from PIL import Image from roboflow.adapters import rfapi -from roboflow.adapters.rfapi import RoboflowError +from roboflow.adapters.rfapi import AnnotationSaveError, ImageUploadError, RoboflowError from roboflow.config import API_URL, CLIP_FEATURIZE_URL, DEMO_KEYS from roboflow.core.project import Project from roboflow.util import folderparser from roboflow.util.active_learning_utils import check_box_size, clip_encode, count_comparisons +from roboflow.util.image_utils import load_labelmap from roboflow.util.two_stage_utils import ocr_infer @@ -308,43 +309,51 @@ def upload_dataset( location = parsed_dataset["location"] - def _log_img_upload(image_path, uploadres): - image_id = uploadres.get("image", {}).get("id") - img_success = uploadres.get("image", {}).get("success") - img_duplicate = uploadres.get("image", {}).get("duplicate") - annotation = uploadres.get("annotation") - image = uploadres.get("image") - upload_time_str = f"[{uploadres['upload_time']:.1f}s]" if uploadres.get("upload_time") else "" - annotation_time_str = f"[{uploadres['annotation_time']:.1f}s]" if uploadres.get("annotation_time") else "" - retry_attempts = ( - f" (with {uploadres['upload_retry_attempts']} retries)" - if uploadres.get("upload_retry_attempts", 0) > 0 - else "" - ) + def _log_img_upload( + image_path, image, annotation, image_upload_time, image_upload_retry_attempts, annotation_time + ): + image_id = image.get("id") + img_success = image.get("success") + img_duplicate = image.get("duplicate") + + upload_time_str = f"[{image_upload_time:.1f}s]" + annotation_time_str = f"[{annotation_time:.1f}s]" if annotation_time else "" + retry_attempts = f" (with {image_upload_retry_attempts} retries)" if image_upload_retry_attempts > 0 else "" + if img_duplicate: msg = f"[DUPLICATE]{retry_attempts} {image_path} ({image_id}) {upload_time_str}" elif img_success: msg = f"[UPLOADED]{retry_attempts} {image_path} ({image_id}) {upload_time_str}" else: - msg = f"[ERR]{retry_attempts} {image_path} ({image}) {upload_time_str}" + msg = f"[LOG ERROR]: Unrecognized image upload status ({image_id=})" if annotation: if annotation.get("success"): msg += f" / annotations = OK {annotation_time_str}" elif annotation.get("warn"): msg += f" / annotations = WARN: {annotation['warn']} {annotation_time_str}" - elif annotation.get("error"): - msg += f" / annotations = ERR: {annotation['error']} {annotation_time_str}" - print(msg) + else: + msg += " / annotations = ERR: Unrecognized annotation upload status" - def _log_img_upload_err(image_path, e): - msg = f"[ERR] {image_path} ({e})" print(msg) def _upload_image(imagedesc): image_path = f"{location}{imagedesc['file']}" split = imagedesc["split"] - annotation_path = None + + image, upload_time, upload_retry_attempts = project.upload_image( + image_path=image_path, + split=split, + batch_name=batch_name, + sequence_number=imagedesc.get("index"), + sequence_size=len(images), + ) + + return image, upload_time, upload_retry_attempts + + def _save_annotation(image_id, imagedesc): labelmap = None + annotation_path = None + annotationdesc = imagedesc.get("annotationfile") if annotationdesc: if annotationdesc.get("rawText"): @@ -352,23 +361,48 @@ def _upload_image(imagedesc): else: annotation_path = f"{location}{annotationdesc['file']}" labelmap = annotationdesc.get("labelmap") + + if isinstance(labelmap, str): + labelmap = load_labelmap(labelmap) + + if not annotation_path: + return None, None + + annotation, upload_time = project.save_annotation( + annotation_path=annotation_path, + annotation_labelmap=labelmap, + image_id=image_id, + job_name=batch_name, + ) + + return annotation, upload_time + + def _upload(imagedesc): + image_path = f"{location}{imagedesc['file']}" + + image_id = None + image_upload_time = None + image_retry_attempts = None + try: - uploadres = project.single_upload( - image_path=image_path, - annotation_path=annotation_path, - annotation_labelmap=labelmap, - split=split, - sequence_number=imagedesc.get("index"), - sequence_size=len(images), - batch_name=batch_name, - num_retry_uploads=num_retries, - ) - _log_img_upload(image_path, uploadres) + image, image_upload_time, image_retry_attempts = _upload_image(imagedesc) + image_id = image["id"] + annotation, annotation_time = _save_annotation(image_id, imagedesc) + _log_img_upload(image_path, image, annotation, image_upload_time, image_retry_attempts, annotation_time) + except ImageUploadError as e: + retry_attempts = f" (with {e.retries} retries)" if e.retries > 0 else "" + print(f"[ERR]{retry_attempts} {image_path} ({e.message})") + except AnnotationSaveError as e: + upload_time_str = f"[{image_upload_time:.1f}s]" + retry_attempts = f" (with {image_retry_attempts} retries)" if image_retry_attempts > 0 else "" + image_msg = f"[UPLOADED]{retry_attempts} {image_path} ({image_id}) {upload_time_str}" + annotation_msg = f"annotations = ERR: {e.message}" + print(f"{image_msg} / {annotation_msg}") except Exception as e: - _log_img_upload_err(image_path, e) + print(f"[ERR] {image_path} ({e})") with concurrent.futures.ThreadPoolExecutor(max_workers=num_workers) as executor: - list(executor.map(_upload_image, images)) + list(executor.map(_upload, images)) def _get_or_create_project(self, project_id, license: str = "MIT", type: str = "object-detection"): try: diff --git a/tests/__init__.py b/tests/__init__.py index 371a3106..63a080c9 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -210,14 +210,6 @@ def setUp(self): status=200, ) - # Upload image - responses.add( - responses.POST, - f"{API_URL}/dataset/{PROJECT_NAME}/upload?api_key={ROBOFLOW_API_KEY}" f"&batch={DEFAULT_BATCH_NAME}", - json={"duplicate": True, "id": "hbALkCFdNr9rssgOUXug"}, - status=200, - ) - self.connect_to_roboflow() def tearDown(self): diff --git a/tests/annotations/invalid_annotation.json b/tests/annotations/invalid_annotation.json new file mode 100644 index 00000000..fd8561a3 --- /dev/null +++ b/tests/annotations/invalid_annotation.json @@ -0,0 +1,8 @@ +{ + "it is": [ + { + "a": 0, + "invalid annotation": true + } + ] +} diff --git a/tests/annotations/valid_annotation.json b/tests/annotations/valid_annotation.json new file mode 100644 index 00000000..8a8284ff --- /dev/null +++ b/tests/annotations/valid_annotation.json @@ -0,0 +1,64 @@ +{ + "info": { + "year": "2020", + "version": "1", + "description": "None", + "contributor": "Linas", + "url": "https://app.roboflow.ai/datasets/hard-hat-sample/1", + "date_created": "2000-01-01T00:00:00+00:00" + }, + "licenses": [ + { + "id": 1, + "url": "https://creativecommons.org/publicdomain/zero/1.0/", + "name": "Public Domain" + } + ], + "categories": [ + { + "id": 0, + "name": "cat", + "supercategory": "animals" + } + ], + "images": [ + { + "id": 0, + "license": 1, + "file_name": "bla.JPG", + "height": 1024, + "width": 1792, + "date_captured": "2020-07-20T19:39:26+00:00" + } + ], + "annotations": [ + { + "id": 0, + "image_id": 0, + "category_id": 0, + "bbox": [ + 45, + 2, + 85, + 85 + ], + "area": 7225, + "segmentation": [], + "iscrowd": 0 + }, + { + "id": 1, + "image_id": 0, + "category_id": 0, + "bbox": [ + 324, + 29, + 72, + 81 + ], + "area": 5832, + "segmentation": [], + "iscrowd": 0 + } + ] +} diff --git a/tests/test_project.py b/tests/test_project.py index e43d9ec3..84b99f96 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -1,4 +1,9 @@ -from tests import RoboflowTest +import responses + +from roboflow import API_URL +from roboflow.adapters.rfapi import AnnotationSaveError, ImageUploadError +from roboflow.config import DEFAULT_BATCH_NAME +from tests import PROJECT_NAME, ROBOFLOW_API_KEY, RoboflowTest class TestProject(RoboflowTest): @@ -21,3 +26,59 @@ def test_check_valid_image_with_unaccepted_formats(self): for image in images_to_test: self.assertFalse(self.project.check_valid_image(f"tests/images/{image}")) + + def test_upload_raises_upload_image_error(self): + responses.add( + responses.POST, + f"{API_URL}/dataset/{PROJECT_NAME}/upload?api_key={ROBOFLOW_API_KEY}" f"&batch={DEFAULT_BATCH_NAME}", + json={ + "error": { + "message": "Invalid image.", + "type": "InvalidImageException", + "hint": "This image was already annotated; to overwrite the annotation, pass overwrite=true...", + } + }, + status=400, + ) + + with self.assertRaises(ImageUploadError) as error: + self.project.upload( + "tests/images/rabbit.JPG", + annotation_path="tests/annotations/valid_annotation.json", + ) + + self.assertEqual(str(error.exception), "Invalid image.") + + def test_upload_raises_upload_annotation_error(self): + image_id = "hbALkCFdNr9rssgOUXug" + image_name = "invalid_annotation.json" + + # Image upload + responses.add( + responses.POST, + f"{API_URL}/dataset/{PROJECT_NAME}/upload?api_key={ROBOFLOW_API_KEY}" f"&batch={DEFAULT_BATCH_NAME}", + json={"success": True, "id": image_id}, + status=200, + ) + + # Annotation + responses.add( + responses.POST, + f"{API_URL}/dataset/{PROJECT_NAME}/annotate/{image_id}?api_key={ROBOFLOW_API_KEY}" f"&name={image_name}", + json={ + "error": { + "message": "Image was already annotated.", + "type": "InvalidImageException", + "hint": "This image was already annotated; to overwrite the annotation, pass overwrite=true...", + } + }, + status=400, + ) + + with self.assertRaises(AnnotationSaveError) as error: + self.project.upload( + "tests/images/rabbit.JPG", + annotation_path=f"tests/annotations/{image_name}", + ) + + self.assertEqual(str(error.exception), "Image was already annotated.") diff --git a/tests/test_queries.py b/tests/test_queries.py index efa7224a..c9a26ed0 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -1,10 +1,14 @@ from _datetime import datetime +import responses + +from roboflow import API_URL +from roboflow.config import DEFAULT_BATCH_NAME from roboflow.core.project import Project from roboflow.core.version import Version from roboflow.models.classification import ClassificationModel from roboflow.models.object_detection import ObjectDetectionModel -from tests import PROJECT_NAME, RoboflowTest, ordered +from tests import PROJECT_NAME, ROBOFLOW_API_KEY, RoboflowTest, ordered class TestQueries(RoboflowTest): @@ -40,6 +44,14 @@ def test_project_fields(self): @ordered def test_project_methods(self): + # Upload image + responses.add( + responses.POST, + f"{API_URL}/dataset/{PROJECT_NAME}/upload?api_key={ROBOFLOW_API_KEY}" f"&batch={DEFAULT_BATCH_NAME}", + json={"duplicate": True, "id": "hbALkCFdNr9rssgOUXug"}, + status=200, + ) + version_information = self.project.get_version_information() print_versions = self.project.list_versions() list_versions = self.project.versions()