Skip to content

Commit 71fcdab

Browse files
committed
Merge branch 'main' into lean/yolov12-upload
2 parents 260fbad + 2b06224 commit 71fcdab

File tree

7 files changed

+202
-28
lines changed

7 files changed

+202
-28
lines changed

roboflow/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@
1515
from roboflow.models import CLIPModel, GazeModel # noqa: F401
1616
from roboflow.util.general import write_line
1717

18+
<<<<<<< HEAD
1819
__version__ = "1.1.55"
20+
=======
21+
__version__ = "1.1.56"
22+
>>>>>>> main
1923

2024

2125
def check_key(api_key, model, notebook, num_retries=0):

roboflow/adapters/rfapi.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import Optional
55

66
import requests
7+
from requests.exceptions import RequestException
78
from requests_toolbelt.multipart.encoder import MultipartEncoder
89

910
from roboflow.config import API_URL, DEFAULT_BATCH_NAME, DEFAULT_JOB_NAME
@@ -26,6 +27,7 @@ class AnnotationSaveError(RoboflowError):
2627
def __init__(self, message, status_code=None):
2728
self.message = message
2829
self.status_code = status_code
30+
self.retries = 0
2931
super().__init__(self.message)
3032

3133

@@ -85,14 +87,21 @@ def upload_image(
8587
"file": ("imageToUpload", imgjpeg, "image/jpeg"),
8688
}
8789
)
88-
response = requests.post(upload_url, data=m, headers={"Content-Type": m.content_type}, timeout=(300, 300))
90+
91+
try:
92+
response = requests.post(upload_url, data=m, headers={"Content-Type": m.content_type}, timeout=(300, 300))
93+
except RequestException as e:
94+
raise ImageUploadError(str(e)) from e
8995

9096
else:
9197
# Hosted image upload url
9298
upload_url = _hosted_upload_url(api_key, project_url, image_path, split, coalesced_batch_name, tag_names)
9399

94-
# Get response
95-
response = requests.post(upload_url, timeout=(300, 300))
100+
try:
101+
# Get response
102+
response = requests.post(upload_url, timeout=(300, 300))
103+
except RequestException as e:
104+
raise ImageUploadError(str(e)) from e
96105

97106
responsejson = None
98107
try:
@@ -147,12 +156,15 @@ def save_annotation(
147156
api_key, project_url, annotation_name, image_id, job_name, is_prediction, overwrite
148157
)
149158

150-
response = requests.post(
151-
upload_url,
152-
data=json.dumps({"annotationFile": annotation_string, "labelmap": annotation_labelmap}),
153-
headers={"Content-Type": "application/json"},
154-
timeout=(60, 60),
155-
)
159+
try:
160+
response = requests.post(
161+
upload_url,
162+
data=json.dumps({"annotationFile": annotation_string, "labelmap": annotation_labelmap}),
163+
headers={"Content-Type": "application/json"},
164+
timeout=(60, 60),
165+
)
166+
except RequestException as e:
167+
raise AnnotationSaveError(str(e)) from e
156168

157169
# Handle response
158170
responsejson = None

roboflow/core/project.py

Lines changed: 79 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import requests
1212

1313
from roboflow.adapters import rfapi
14-
from roboflow.adapters.rfapi import ImageUploadError
14+
from roboflow.adapters.rfapi import AnnotationSaveError, ImageUploadError
1515
from roboflow.config import API_URL, DEMO_KEYS
1616
from roboflow.core.version import Version
1717
from roboflow.util.general import Retry
@@ -515,26 +515,34 @@ def save_annotation(
515515
job_name=None,
516516
is_prediction: bool = False,
517517
annotation_overwrite=False,
518+
num_retry_uploads=0,
518519
):
519520
project_url = self.id.rsplit("/")[1]
520521
annotation_name, annotation_str = self._annotation_params(annotation_path)
521522
t0 = time.time()
523+
upload_retry_attempts = 0
524+
retry = Retry(num_retry_uploads, AnnotationSaveError)
522525

523-
annotation = rfapi.save_annotation(
524-
self.__api_key,
525-
project_url,
526-
annotation_name, # type: ignore[type-var]
527-
annotation_str, # type: ignore[type-var]
528-
image_id,
529-
job_name=job_name, # type: ignore[type-var]
530-
is_prediction=is_prediction,
531-
annotation_labelmap=annotation_labelmap,
532-
overwrite=annotation_overwrite,
533-
)
526+
try:
527+
annotation = rfapi.save_annotation(
528+
self.__api_key,
529+
project_url,
530+
annotation_name, # type: ignore[type-var]
531+
annotation_str, # type: ignore[type-var]
532+
image_id,
533+
job_name=job_name, # type: ignore[type-var]
534+
is_prediction=is_prediction,
535+
annotation_labelmap=annotation_labelmap,
536+
overwrite=annotation_overwrite,
537+
)
538+
upload_retry_attempts = retry.retries
539+
except AnnotationSaveError as e:
540+
e.retries = upload_retry_attempts
541+
raise
534542

535543
upload_time = time.time() - t0
536544

537-
return annotation, upload_time
545+
return annotation, upload_time, upload_retry_attempts
538546

539547
def single_upload(
540548
self,
@@ -563,6 +571,7 @@ def single_upload(
563571
uploaded_image, uploaded_annotation = None, None
564572
upload_time, annotation_time = None, None
565573
upload_retry_attempts = 0
574+
annotation_upload_retry_attempts = 0
566575

567576
if image_path:
568577
uploaded_image, upload_time, upload_retry_attempts = self.upload_image(
@@ -579,13 +588,14 @@ def single_upload(
579588
image_id = uploaded_image["id"] # type: ignore[index]
580589

581590
if annotation_path and image_id:
582-
uploaded_annotation, annotation_time = self.save_annotation(
591+
uploaded_annotation, annotation_time, annotation_upload_retry_attempts = self.save_annotation(
583592
annotation_path,
584593
annotation_labelmap,
585594
image_id,
586595
batch_name,
587596
is_prediction,
588597
annotation_overwrite,
598+
num_retry_uploads=num_retry_uploads,
589599
)
590600

591601
return {
@@ -594,6 +604,7 @@ def single_upload(
594604
"upload_time": upload_time,
595605
"annotation_time": annotation_time,
596606
"upload_retry_attempts": upload_retry_attempts,
607+
"annotation_upload_retry_attempts": annotation_upload_retry_attempts,
597608
}
598609

599610
def _annotation_params(self, annotation_path):
@@ -801,3 +812,57 @@ def image(self, image_id: str) -> Dict:
801812
image_details = data["image"]
802813

803814
return image_details
815+
816+
def create_annotation_job(
817+
self, name: str, batch_id: str, num_images: int, labeler_email: str, reviewer_email: str
818+
) -> Dict:
819+
"""
820+
Create a new annotation job in the project.
821+
822+
Args:
823+
name (str): The name of the annotation job
824+
batch_id (str): The ID of the batch that contains the images to annotate
825+
num_images (int): The number of images to include in the job
826+
labeler_email (str): The email of the user who will label the images
827+
reviewer_email (str): The email of the user who will review the annotations
828+
829+
Returns:
830+
Dict: A dictionary containing the created job details
831+
832+
Example:
833+
>>> import roboflow
834+
835+
>>> rf = roboflow.Roboflow(api_key="YOUR_API_KEY")
836+
837+
>>> project = rf.workspace().project("PROJECT_ID")
838+
839+
>>> job = project.create_annotation_job(
840+
... name="Job created by API",
841+
... batch_id="batch123",
842+
... num_images=10,
843+
... labeler_email="[email protected]",
844+
... reviewer_email="[email protected]"
845+
... )
846+
"""
847+
url = f"{API_URL}/{self.__workspace}/{self.__project_name}/jobs?api_key={self.__api_key}"
848+
849+
payload = {
850+
"name": name,
851+
"batch": batch_id,
852+
"num_images": num_images,
853+
"labelerEmail": labeler_email,
854+
"reviewerEmail": reviewer_email,
855+
}
856+
857+
response = requests.post(url, headers={"Content-Type": "application/json"}, json=payload)
858+
859+
if response.status_code != 200:
860+
try:
861+
error_data = response.json()
862+
if "error" in error_data:
863+
raise RuntimeError(error_data["error"])
864+
raise RuntimeError(response.text)
865+
except ValueError:
866+
raise RuntimeError(f"Failed to create annotation job: {response.text}")
867+
868+
return response.json()

roboflow/core/workspace.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,7 @@ def _upload_image(imagedesc):
350350
batch_name=batch_name,
351351
sequence_number=imagedesc.get("index"),
352352
sequence_size=len(images),
353+
num_retry_uploads=num_retries,
353354
)
354355

355356
return image, upload_time, upload_retry_attempts
@@ -372,11 +373,12 @@ def _save_annotation(image_id, imagedesc):
372373
if not annotation_path:
373374
return None, None
374375

375-
annotation, upload_time = project.save_annotation(
376+
annotation, upload_time, _retry_attempts = project.save_annotation(
376377
annotation_path=annotation_path,
377378
annotation_labelmap=labelmap,
378379
image_id=image_id,
379380
job_name=batch_name,
381+
num_retry_uploads=num_retries,
380382
)
381383

382384
return annotation, upload_time

roboflow/roboflowpy.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ def upload_model(args):
8181

8282
if args.version_number is not None:
8383
# Deploy to specific version
84-
project = workspace.project(args.project)
84+
project_id = args.project[0] if isinstance(args.project, list) else args.project
85+
project = workspace.project(project_id)
8586
version = project.version(args.version_number)
8687
version.deploy(str(args.model_type), str(args.model_path), str(args.filename))
8788
else:

roboflow/util/general.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import sys
2+
import time
3+
from random import random
24

35

46
def write_line(line):
@@ -13,8 +15,16 @@ def __init__(self, max_retries, retry_on):
1315
self.retry_on = retry_on
1416
self.retries = 0
1517

18+
def backoff(self):
19+
"""
20+
Backoff for a random time based on number of retries.
21+
"""
22+
base_t_ms = 100
23+
max_t_ms = 30000
24+
sleep_ms = random() * min(max_t_ms, base_t_ms * 2**self.retries)
25+
time.sleep(int(sleep_ms) / 1000)
26+
1627
def __call__(self, func, *args, **kwargs):
17-
self.retries = 0
1828
retry_on = self.retry_on
1929
if not retry_on:
2030
retry_on = (Exception,)
@@ -24,8 +34,9 @@ def __call__(self, func, *args, **kwargs):
2434
return func(*args, **kwargs)
2535
except BaseException as e:
2636
if isinstance(e, retry_on):
27-
self.retries += 1
28-
if self.retries > self.max_retries:
37+
if self.retries >= self.max_retries:
2938
raise
39+
self.backoff()
40+
self.retries += 1
3041
else:
3142
raise

tests/test_project.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import requests
22
import responses
3+
from responses.matchers import json_params_matcher
34

45
from roboflow import API_URL
56
from roboflow.adapters.rfapi import AnnotationSaveError, ImageUploadError
@@ -144,3 +145,81 @@ def test_image_invalid_json_response(self):
144145
self.project.image(image_id)
145146

146147
self.assertIn("Expecting value", str(context.exception))
148+
149+
def test_create_annotation_job_success(self):
150+
job_name = "Test Job"
151+
batch_id = "test-batch-123"
152+
num_images = 10
153+
labeler_email = "[email protected]"
154+
reviewer_email = "[email protected]"
155+
156+
expected_response = {
157+
"success": True,
158+
"job": {
159+
"id": "job-123",
160+
"name": job_name,
161+
"batch": batch_id,
162+
"num_images": num_images,
163+
"labeler": labeler_email,
164+
"reviewer": reviewer_email,
165+
"status": "created",
166+
"created": 1616161616,
167+
},
168+
}
169+
170+
expected_url = f"{API_URL}/{WORKSPACE_NAME}/{PROJECT_NAME}/jobs?api_key={ROBOFLOW_API_KEY}"
171+
172+
responses.add(
173+
responses.POST,
174+
expected_url,
175+
json=expected_response,
176+
status=200,
177+
match=[
178+
json_params_matcher(
179+
{
180+
"name": job_name,
181+
"batch": batch_id,
182+
"num_images": num_images,
183+
"labelerEmail": labeler_email,
184+
"reviewerEmail": reviewer_email,
185+
}
186+
)
187+
],
188+
)
189+
190+
result = self.project.create_annotation_job(
191+
name=job_name,
192+
batch_id=batch_id,
193+
num_images=num_images,
194+
labeler_email=labeler_email,
195+
reviewer_email=reviewer_email,
196+
)
197+
198+
self.assertEqual(result, expected_response)
199+
self.assertTrue(result["success"])
200+
self.assertEqual(result["job"]["id"], "job-123")
201+
self.assertEqual(result["job"]["name"], job_name)
202+
203+
def test_create_annotation_job_error(self):
204+
job_name = "Test Job"
205+
batch_id = "invalid-batch"
206+
num_images = 10
207+
labeler_email = "[email protected]"
208+
reviewer_email = "[email protected]"
209+
210+
error_response = {"error": "Batch not found"}
211+
212+
expected_url = f"{API_URL}/{WORKSPACE_NAME}/{PROJECT_NAME}/jobs?api_key={ROBOFLOW_API_KEY}"
213+
214+
responses.add(responses.POST, expected_url, json=error_response, status=404)
215+
216+
with self.assertRaises(RuntimeError) as context:
217+
self.project.create_annotation_job(
218+
name=job_name,
219+
batch_id=batch_id,
220+
num_images=num_images,
221+
labeler_email=labeler_email,
222+
reviewer_email=reviewer_email,
223+
)
224+
225+
self.assertEqual(str(context.exception), "Batch not found")

0 commit comments

Comments
 (0)