Skip to content

Commit a09a72a

Browse files
tonylampadaclaude
andcommitted
Honor annotation_overwrite in per-image upload_dataset; truthful duplicate log (LEN-122)
The per-image flow never forwarded annotation_overwrite, so upload_dataset(annotation_overwrite=True) on a directory could not replace annotations on duplicate images. Resolve the default once for both flows and pass it to save_annotation. When the API keeps existing annotations (409 already annotated), log that instead of a bare WARN. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent b9ec136 commit a09a72a

3 files changed

Lines changed: 66 additions & 5 deletions

File tree

‎roboflow/cli/handlers/image.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def upload_image(
4444
Optional[bool],
4545
typer.Option(
4646
"--annotation-overwrite/--no-annotation-overwrite",
47-
help="Zip flow: overwrite existing annotations on duplicate images "
47+
help="Overwrite existing annotations on duplicate images "
4848
"(default: off, except classification projects where the API requires it on)",
4949
),
5050
] = None,

‎roboflow/core/workspace.py‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -540,7 +540,7 @@ def upload_dataset(
540540
num_retries (int, optional): number of times to retry uploading an image if the upload fails. Defaults to 0.
541541
is_prediction (bool, optional): whether the annotations provided in the dataset are predictions and not ground truth. Defaults to False.
542542
use_zip_upload (bool, optional): opt-in to the zip flow for a directory input (the SDK zips it client-side). Ignored when dataset_path is already a `.zip`.
543-
annotation_overwrite (bool, optional): zip flow only — overwrite existing annotations on duplicate images. Defaults to False, except classification projects where it defaults to True (the API requires it).
543+
annotation_overwrite (bool, optional): overwrite existing annotations on duplicate images. Defaults to False, except classification projects where it defaults to True (the API requires it).
544544
tags (list[str], optional): zip flow only — tags to apply to the uploaded batch.
545545
split (str, optional): dataset split for the uploaded batch. In per-image directory
546546
uploads, this overrides inferred splits for every image.
@@ -569,6 +569,9 @@ def upload_dataset(
569569
"Call upload_dataset without use_zip_upload for prediction uploads."
570570
)
571571

572+
if annotation_overwrite is None:
573+
annotation_overwrite = project.type == "classification"
574+
572575
if use_zip_flow:
573576
project_slug = project.id.rsplit("/")[1]
574577
temp_zip = None
@@ -579,9 +582,6 @@ def upload_dataset(
579582
zip_path = temp_zip = _zip_directory(dataset_path)
580583
print(f"Zipped {dataset_path} -> {zip_path}")
581584

582-
if annotation_overwrite is None:
583-
annotation_overwrite = project.type == "classification"
584-
585585
init = rfapi.init_zip_upload(
586586
self.__api_key,
587587
self.url,
@@ -635,6 +635,8 @@ def _log_img_upload(
635635
if annotation:
636636
if annotation.get("success"):
637637
msg += f" / annotations = OK {annotation_time_str}"
638+
elif annotation.get("warn") == "already annotated":
639+
msg += " / annotations = kept existing (pass annotation_overwrite=True to replace)"
638640
elif annotation.get("warn"):
639641
msg += f" / annotations = WARN: {annotation['warn']} {annotation_time_str}"
640642
else:
@@ -687,6 +689,7 @@ def _save_annotation(image_id, imagedesc):
687689
job_name=batch_name,
688690
num_retry_uploads=num_retries,
689691
is_prediction=is_prediction,
692+
annotation_overwrite=annotation_overwrite,
690693
)
691694

692695
return annotation, upload_time

‎tests/test_project.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1218,3 +1218,61 @@ def test_poll_loop_timeout(self):
12181218

12191219
if _os.path.exists(zip_path):
12201220
_os.unlink(zip_path)
1221+
1222+
1223+
class TestPerImageAnnotationOverwrite(RoboflowTest):
1224+
"""upload_dataset on a directory (per-image flow) forwards annotation_overwrite to the annotate POST."""
1225+
1226+
def _upload(self, image, annotate_status=200, annotate_json=None, **kwargs):
1227+
import re
1228+
1229+
dataset = {
1230+
"location": "/test/location/",
1231+
"images": [
1232+
{
1233+
"file": "img.jpg",
1234+
"split": "train",
1235+
"annotationfile": {"name": "annotation.coco.json", "rawText": "{}"},
1236+
}
1237+
],
1238+
}
1239+
responses.add(
1240+
responses.POST,
1241+
re.compile(rf"{API_URL}/dataset/{PROJECT_NAME}/annotate/img-id\?.*"),
1242+
json=annotate_json or {"success": True},
1243+
status=annotate_status,
1244+
)
1245+
with (
1246+
patch("roboflow.util.folderparser.parsefolder", return_value=dataset),
1247+
patch("roboflow.core.project.Project.upload_image", return_value=(image, 0.1, 0)),
1248+
patch("roboflow.core.workspace.Workspace._get_or_create_project", return_value=(self.project, False)),
1249+
patch("builtins.print") as mock_print,
1250+
):
1251+
self.workspace.upload_dataset(dataset_path="/test/dataset", project_name=PROJECT_NAME, **kwargs)
1252+
annotate_calls = [c for c in responses.calls if "/annotate/" in c.request.url]
1253+
logs = " ".join(str(c.args[0]) for c in mock_print.call_args_list if c.args)
1254+
return annotate_calls, logs
1255+
1256+
@responses.activate
1257+
def test_fresh_image_annotates_without_overwrite(self):
1258+
calls, logs = self._upload({"id": "img-id", "success": True})
1259+
self.assertEqual(len(calls), 1)
1260+
self.assertNotIn("overwrite=true", calls[0].request.url)
1261+
self.assertIn("annotations = OK", logs)
1262+
1263+
@responses.activate
1264+
def test_duplicate_without_overwrite_keeps_existing_annotations(self):
1265+
calls, logs = self._upload(
1266+
{"id": "img-id", "duplicate": True},
1267+
annotate_status=409,
1268+
annotate_json={"error": {"message": "Image was already annotated. To overwrite, pass overwrite=true."}},
1269+
)
1270+
self.assertEqual(len(calls), 1)
1271+
self.assertNotIn("overwrite=true", calls[0].request.url)
1272+
self.assertIn("annotations = kept existing (pass annotation_overwrite=True to replace)", logs)
1273+
1274+
@responses.activate
1275+
def test_duplicate_with_overwrite_true_sends_overwrite(self):
1276+
calls, _ = self._upload({"id": "img-id", "duplicate": True}, annotation_overwrite=True)
1277+
self.assertEqual(len(calls), 1)
1278+
self.assertIn("overwrite=true", calls[0].request.url)

0 commit comments

Comments
 (0)