From b94b45d80ee9a8c0f8d86081402e3b6bd1fdd6ce Mon Sep 17 00:00:00 2001 From: Iuri de Silvio Date: Wed, 28 Oct 2026 16:12:01 +0100 Subject: [PATCH 1/3] Add merge_existing_annotations to Project.autolabel and the CLI The Auto Label API takes mergeExistingAnnotations: keep the annotations already on the batch images and add only the predictions that don't overlap an existing annotation of the same class (IoU >= 0.5). Project.autolabel(merge_existing_annotations=True) and `roboflow autolabel start --merge-existing` send it. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 +++++++++++ CLI-COMMANDS.md | 5 ++++- roboflow/adapters/rfapi.py | 7 ++++++- roboflow/cli/handlers/autolabel.py | 9 +++++++++ roboflow/core/project.py | 7 +++++++ tests/adapters/test_autolabel.py | 2 ++ tests/cli/test_autolabel_handler.py | 12 ++++++++++++ tests/test_project_autolabel.py | 7 +++++++ 8 files changed, 58 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5427d878..ec376032 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project will be documented in this file. +## Unreleased + +### Added + +- `Project.autolabel(..., merge_existing_annotations=True)` and + `roboflow autolabel start --merge-existing` keep the annotations already on the + batch images and add only the predictions that don't overlap an existing + annotation of the same class (IoU of at least 0.5), so objects that are already + labeled aren't duplicated. It wins over `preserve_existing_annotations`, which + adds every prediction. + ## 1.7.3 ### Added diff --git a/CLI-COMMANDS.md b/CLI-COMMANDS.md index 11be906d..f9a7e0b6 100644 --- a/CLI-COMMANDS.md +++ b/CLI-COMMANDS.md @@ -320,6 +320,7 @@ roboflow autolabel start -p my-project --batch-id -m gpt-6-astra-boxe --ontology '{"a cat": "cat", "a dog": "dog"}' --confidence 0.5 --reviewer b@co.com roboflow autolabel start -p my-project --batch-id -m my-project/3 --model-type roboflow roboflow autolabel start -p my-project --batch-id -m sam3-rle --preserve-existing +roboflow autolabel start -p my-project --batch-id -m sam3-rle --merge-existing roboflow autolabel job roboflow autolabel job -p other-workspace/my-project ``` @@ -335,7 +336,9 @@ prompts share one output class. JSON options also accept a curl-style file reference (`--ontology @ontology.json`). `--image` accepts an HTTPS URL or a local file. By default a job replaces the annotations already on the batch images; `--preserve-existing` keeps them and -only adds new ones. `job` looks the id up in your default workspace, so when +adds every prediction next to them, and `--merge-existing` keeps them and skips +a prediction that overlaps an existing annotation of the same class (IoU of at +least 0.5), so objects that are already labeled aren't duplicated. `job` looks the id up in your default workspace, so when the job was started with a `workspace/project` shorthand pass the same `-p` to `job`. diff --git a/roboflow/adapters/rfapi.py b/roboflow/adapters/rfapi.py index d7b69e0e..b386caa8 100644 --- a/roboflow/adapters/rfapi.py +++ b/roboflow/adapters/rfapi.py @@ -1542,6 +1542,7 @@ def start_autolabel_job( reviewer_email=None, model_options=None, preserve_existing_annotations=None, + merge_existing_annotations=None, ): """Start a hosted auto-label job over a batch. @@ -1555,7 +1556,10 @@ def start_autolabel_job( ``confidence_thresholds`` is omitted and defaults ``num_images_to_label`` to the whole batch. ``preserve_existing_annotations`` keeps annotations already on the images and only adds new ones; the server default (False) - replaces them. Returns ``{jobId, annotationJobId, message}``. + replaces them. ``merge_existing_annotations`` also keeps them but skips a + prediction that overlaps an existing annotation of the same class (IoU + >= 0.5); it wins over ``preserve_existing_annotations``. Returns + ``{jobId, annotationJobId, message}``. """ payload = {"batchId": batch_id, "modelType": model_type} optional = { @@ -1567,6 +1571,7 @@ def start_autolabel_job( "reviewerEmail": reviewer_email, "modelOptions": model_options, "preserveExistingAnnotations": preserve_existing_annotations, + "mergeExistingAnnotations": merge_existing_annotations, } payload.update({key: value for key, value in optional.items() if value is not None}) response = http.post( diff --git a/roboflow/cli/handlers/autolabel.py b/roboflow/cli/handlers/autolabel.py index 0fe3eab2..fad8f77b 100644 --- a/roboflow/cli/handlers/autolabel.py +++ b/roboflow/cli/handlers/autolabel.py @@ -115,6 +115,14 @@ def start( "(by default the job replaces them)", ), ] = False, + merge_existing: Annotated[ + bool, + typer.Option( + "--merge-existing", + help="Keep annotations already on the batch images and only add predictions that don't " + "overlap an existing annotation of the same class", + ), + ] = False, ) -> None: """Start a hosted auto-label job over a batch of images.""" args = ctx_to_args(ctx, project=project) @@ -139,6 +147,7 @@ def start_job(key: str, workspace: str, proj: str) -> Any: reviewer_email=reviewer, model_options=wire_options, preserve_existing_annotations=True if preserve_existing else None, + merge_existing_annotations=True if merge_existing else None, ) _project_command(args, start_job) diff --git a/roboflow/core/project.py b/roboflow/core/project.py index 0f2003f3..f2436ca1 100644 --- a/roboflow/core/project.py +++ b/roboflow/core/project.py @@ -1374,6 +1374,7 @@ def autolabel( reviewer_email: Optional[str] = None, model_options: Optional[Dict] = None, preserve_existing_annotations: Optional[bool] = None, + merge_existing_annotations: Optional[bool] = None, ) -> Dict: """Start a hosted auto-label job over a batch of images. @@ -1410,6 +1411,11 @@ def autolabel( on the batch images and only adds new ones. The server default (``False``) replaces them, so set this when the batch contains images that were already labeled or reviewed. + merge_existing_annotations: ``True`` keeps annotations already on + the batch images and adds only the predictions that don't + overlap an existing annotation of the same class (IoU >= 0.5), + so objects that are already labeled aren't duplicated. Wins over + ``preserve_existing_annotations``. Returns: Dict: ``{jobId, annotationJobId, message}``. Poll progress with @@ -1435,6 +1441,7 @@ def autolabel( reviewer_email=reviewer_email, model_options=model_options, preserve_existing_annotations=preserve_existing_annotations, + merge_existing_annotations=merge_existing_annotations, ) def autolabel_job(self, job_id: str) -> Dict: diff --git a/tests/adapters/test_autolabel.py b/tests/adapters/test_autolabel.py index 091d5a14..8ad4bc56 100644 --- a/tests/adapters/test_autolabel.py +++ b/tests/adapters/test_autolabel.py @@ -82,6 +82,7 @@ def test_start_job_contract_full_payload(self, mock_post): reviewer_email="reviewer@example.com", model_options={"modelId": "proj/3"}, preserve_existing_annotations=True, + merge_existing_annotations=True, ) self.assertEqual( mock_post.call_args.kwargs["json"], @@ -96,6 +97,7 @@ def test_start_job_contract_full_payload(self, mock_post): "reviewerEmail": "reviewer@example.com", "modelOptions": {"modelId": "proj/3"}, "preserveExistingAnnotations": True, + "mergeExistingAnnotations": True, }, ) diff --git a/tests/cli/test_autolabel_handler.py b/tests/cli/test_autolabel_handler.py index 23990d9c..2c798991 100644 --- a/tests/cli/test_autolabel_handler.py +++ b/tests/cli/test_autolabel_handler.py @@ -199,6 +199,7 @@ def test_foundational_start(self, _resolve, mock_api): reviewer_email="r@example.com", model_options={"outputFormat": "polygon"}, preserve_existing_annotations=None, + merge_existing_annotations=None, ) @patch("roboflow.adapters.rfapi.start_autolabel_job", return_value={"jobId": "job-1"}) @@ -212,6 +213,17 @@ def test_preserve_existing_flag(self, _resolve, mock_api): self.assertEqual(result.exit_code, 0, result.output) self.assertIs(mock_api.call_args.kwargs["preserve_existing_annotations"], True) + @patch("roboflow.adapters.rfapi.start_autolabel_job", return_value={"jobId": "job-1"}) + @patch(_RESOLVE_PROJECT, return_value=("key", "ws", "proj")) + def test_merge_existing_flag(self, _resolve, mock_api): + result = runner.invoke( + app, + ["autolabel", "start", "-p", "ws/proj", "--batch-id", "batch-1", "-m", "sam3-rle", "--merge-existing"], + ) + self.assertEqual(result.exit_code, 0, result.output) + self.assertIs(mock_api.call_args.kwargs["merge_existing_annotations"], True) + self.assertIsNone(mock_api.call_args.kwargs["preserve_existing_annotations"]) + @patch("roboflow.adapters.rfapi.start_autolabel_job", return_value={"jobId": "job-1"}) @patch(_RESOLVE_PROJECT, return_value=("key", "ws", "proj")) def test_roboflow_model_type(self, _resolve, mock_api): diff --git a/tests/test_project_autolabel.py b/tests/test_project_autolabel.py index d6f080cb..c9665244 100644 --- a/tests/test_project_autolabel.py +++ b/tests/test_project_autolabel.py @@ -32,6 +32,7 @@ def test_autolabel_foundational_passes_model_as_is(self, mock_start): reviewer_email="reviewer@example.com", model_options=None, preserve_existing_annotations=None, + merge_existing_annotations=None, ) @patch("roboflow.adapters.rfapi.start_autolabel_job", return_value={"jobId": "job-1"}) @@ -40,6 +41,12 @@ def test_autolabel_forwards_preserve_existing_annotations(self, mock_start): self.assertIs(mock_start.call_args.kwargs["preserve_existing_annotations"], True) + @patch("roboflow.adapters.rfapi.start_autolabel_job", return_value={"jobId": "job-1"}) + def test_autolabel_forwards_merge_existing_annotations(self, mock_start): + self.project.autolabel("batch-1", "sam3-rle", merge_existing_annotations=True) + + self.assertIs(mock_start.call_args.kwargs["merge_existing_annotations"], True) + @patch("roboflow.adapters.rfapi.start_autolabel_job", return_value={"jobId": "job-1"}) def test_autolabel_roboflow_model_is_sent_as_custom_roboflow(self, mock_start): self.project.autolabel("batch-1", "my-project/3", model_type="roboflow", model_options={"outputFormat": "rle"}) From d5e7bdba5484eef39d296058cdbe26484fbf4c1f Mon Sep 17 00:00:00 2001 From: Iuri de Silvio Date: Wed, 28 Oct 2026 17:11:50 +0100 Subject: [PATCH 2/3] Choose the existing annotations mode with one parameter Project.autolabel takes existing_annotations="replace" | "preserve" | "merge" and the CLI takes --existing-annotations, the same three modes the server has. preserve_existing_annotations still works with a DeprecationWarning, and --preserve-existing is a hidden alias. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 19 +++++++++++------ CLI-COMMANDS.md | 14 ++++++------- roboflow/adapters/rfapi.py | 19 ++++++++--------- roboflow/cli/handlers/autolabel.py | 24 +++++++++------------- roboflow/core/project.py | 32 +++++++++++++++++++---------- roboflow/util/autolabel_utils.py | 17 +++++++++++++++ tests/adapters/test_autolabel.py | 11 ++++++++-- tests/cli/test_autolabel_handler.py | 29 ++++++++++++++++++-------- tests/test_project_autolabel.py | 25 ++++++++++++++-------- tests/util/test_autolabel_utils.py | 21 ++++++++++++++++++- 10 files changed, 143 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec376032..0fb66bbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,19 @@ All notable changes to this project will be documented in this file. ### Added -- `Project.autolabel(..., merge_existing_annotations=True)` and - `roboflow autolabel start --merge-existing` keep the annotations already on the - batch images and add only the predictions that don't overlap an existing - annotation of the same class (IoU of at least 0.5), so objects that are already - labeled aren't duplicated. It wins over `preserve_existing_annotations`, which - adds every prediction. +- `Project.autolabel(..., existing_annotations=...)` and + `roboflow autolabel start --existing-annotations ...` choose what a job does + with annotations already on the batch images: `"replace"` (the default) + removes them, `"preserve"` keeps them and adds every prediction, and + `"merge"` keeps them and adds only the predictions that don't overlap an + existing annotation of the same class (IoU of at least 0.5), so objects that + are already labeled aren't duplicated. + +### Deprecated + +- `Project.autolabel(..., preserve_existing_annotations=True)` still works but + warns; use `existing_annotations="preserve"`. `--preserve-existing` is now a + hidden alias of `--existing-annotations preserve`. ## 1.7.3 diff --git a/CLI-COMMANDS.md b/CLI-COMMANDS.md index f9a7e0b6..b3ff50c8 100644 --- a/CLI-COMMANDS.md +++ b/CLI-COMMANDS.md @@ -319,8 +319,7 @@ roboflow autolabel preview -p my-project -m sam3-rle --image https://example.com roboflow autolabel start -p my-project --batch-id -m gpt-6-astra-boxes \ --ontology '{"a cat": "cat", "a dog": "dog"}' --confidence 0.5 --reviewer b@co.com roboflow autolabel start -p my-project --batch-id -m my-project/3 --model-type roboflow -roboflow autolabel start -p my-project --batch-id -m sam3-rle --preserve-existing -roboflow autolabel start -p my-project --batch-id -m sam3-rle --merge-existing +roboflow autolabel start -p my-project --batch-id -m sam3-rle --existing-annotations merge roboflow autolabel job roboflow autolabel job -p other-workspace/my-project ``` @@ -334,11 +333,12 @@ repeated `--class` flags or as `--ontology` JSON. The ontology is keyed by matches either prompt as class `cat`. That direction is what lets several prompts share one output class. JSON options also accept a curl-style file reference (`--ontology @ontology.json`). -`--image` accepts an HTTPS URL or a local file. By default a job replaces the -annotations already on the batch images; `--preserve-existing` keeps them and -adds every prediction next to them, and `--merge-existing` keeps them and skips -a prediction that overlaps an existing annotation of the same class (IoU of at -least 0.5), so objects that are already labeled aren't duplicated. `job` looks the id up in your default workspace, so when +`--image` accepts an HTTPS URL or a local file. `--existing-annotations` says +what the job does with annotations already on the batch images: `replace` (the +default) removes them, `preserve` keeps them and adds every prediction next to +them, and `merge` keeps them and skips a prediction that overlaps an existing +annotation of the same class (IoU of at least 0.5), so objects that are already +labeled aren't duplicated. `job` looks the id up in your default workspace, so when the job was started with a `workspace/project` shorthand pass the same `-p` to `job`. diff --git a/roboflow/adapters/rfapi.py b/roboflow/adapters/rfapi.py index b386caa8..e12bd50b 100644 --- a/roboflow/adapters/rfapi.py +++ b/roboflow/adapters/rfapi.py @@ -12,6 +12,7 @@ from roboflow.config import API_URL, DEFAULT_BATCH_NAME, DEFAULT_JOB_NAME from roboflow.util import http +from roboflow.util.autolabel_utils import existing_annotations_flags from roboflow.util.search_utils import normalize_media_types @@ -1541,8 +1542,7 @@ def start_autolabel_job( run_nms=None, reviewer_email=None, model_options=None, - preserve_existing_annotations=None, - merge_existing_annotations=None, + existing_annotations=None, ): """Start a hosted auto-label job over a batch. @@ -1554,12 +1554,12 @@ def start_autolabel_job( The backend fans ``default_confidence`` out across the ontology when ``confidence_thresholds`` is omitted and defaults ``num_images_to_label`` - to the whole batch. ``preserve_existing_annotations`` keeps annotations - already on the images and only adds new ones; the server default (False) - replaces them. ``merge_existing_annotations`` also keeps them but skips a - prediction that overlaps an existing annotation of the same class (IoU - >= 0.5); it wins over ``preserve_existing_annotations``. Returns - ``{jobId, annotationJobId, message}``. + to the whole batch. ``existing_annotations`` is ``"replace"`` (the server + default), ``"preserve"`` or ``"merge"``; see ``Project.autolabel``. + Returns ``{jobId, annotationJobId, message}``. + + Raises: + ValueError: ``existing_annotations`` is not one of those modes. """ payload = {"batchId": batch_id, "modelType": model_type} optional = { @@ -1570,10 +1570,9 @@ def start_autolabel_job( "runNMS": run_nms, "reviewerEmail": reviewer_email, "modelOptions": model_options, - "preserveExistingAnnotations": preserve_existing_annotations, - "mergeExistingAnnotations": merge_existing_annotations, } payload.update({key: value for key, value in optional.items() if value is not None}) + payload.update(existing_annotations_flags(existing_annotations)) response = http.post( f"{API_URL}/{workspace_url}/{project_url}/autolabel", params={"api_key": api_key}, diff --git a/roboflow/cli/handlers/autolabel.py b/roboflow/cli/handlers/autolabel.py index fad8f77b..6f02ea28 100644 --- a/roboflow/cli/handlers/autolabel.py +++ b/roboflow/cli/handlers/autolabel.py @@ -107,21 +107,18 @@ def start( "--model-options", help='JSON model options, e.g. \'{"outputFormat": "polygon"}\', or @options.json' ), ] = None, - preserve_existing: Annotated[ - bool, + existing_annotations: Annotated[ + Optional[str], typer.Option( - "--preserve-existing", - help="Keep annotations already on the batch images and only add new ones " - "(by default the job replaces them)", + "--existing-annotations", + help="What to do with annotations already on the batch images: 'replace' (default) removes " + "them, 'preserve' keeps them and adds every prediction, 'merge' keeps them and skips " + "predictions that overlap an existing annotation of the same class", ), - ] = False, - merge_existing: Annotated[ + ] = None, + preserve_existing: Annotated[ bool, - typer.Option( - "--merge-existing", - help="Keep annotations already on the batch images and only add predictions that don't " - "overlap an existing annotation of the same class", - ), + typer.Option("--preserve-existing", hidden=True, help="Same as --existing-annotations preserve"), ] = False, ) -> None: """Start a hosted auto-label job over a batch of images.""" @@ -146,8 +143,7 @@ def start_job(key: str, workspace: str, proj: str) -> Any: run_nms=False if no_nms else None, reviewer_email=reviewer, model_options=wire_options, - preserve_existing_annotations=True if preserve_existing else None, - merge_existing_annotations=True if merge_existing else None, + existing_annotations=existing_annotations or ("preserve" if preserve_existing else None), ) _project_command(args, start_job) diff --git a/roboflow/core/project.py b/roboflow/core/project.py index f2436ca1..d6a8394e 100644 --- a/roboflow/core/project.py +++ b/roboflow/core/project.py @@ -1374,7 +1374,7 @@ def autolabel( reviewer_email: Optional[str] = None, model_options: Optional[Dict] = None, preserve_existing_annotations: Optional[bool] = None, - merge_existing_annotations: Optional[bool] = None, + existing_annotations: Optional[str] = None, ) -> Dict: """Start a hosted auto-label job over a batch of images. @@ -1407,15 +1407,17 @@ def autolabel( workspace member; defaults to the workspace owner. model_options: Model-specific options, e.g. ``{"outputFormat": "polygon"}`` for segmentation output. - preserve_existing_annotations: ``True`` keeps annotations already - on the batch images and only adds new ones. The server default - (``False``) replaces them, so set this when the batch contains - images that were already labeled or reviewed. - merge_existing_annotations: ``True`` keeps annotations already on - the batch images and adds only the predictions that don't + preserve_existing_annotations: Deprecated, use + ``existing_annotations="preserve"``. ``True`` is the same as + that mode; ``existing_annotations`` wins when both are set. + existing_annotations: What to do with annotations already on the + batch images. ``"replace"`` (the server default) removes them, + ``"preserve"`` keeps them and adds every prediction, and + ``"merge"`` keeps them and adds only the predictions that don't overlap an existing annotation of the same class (IoU >= 0.5), - so objects that are already labeled aren't duplicated. Wins over - ``preserve_existing_annotations``. + so objects that are already labeled aren't duplicated. Use + ``"preserve"`` or ``"merge"`` when the batch contains images + that were already labeled or reviewed. Returns: Dict: ``{jobId, annotationJobId, message}``. Poll progress with @@ -1426,6 +1428,15 @@ def autolabel( ... ontology={"a cat": "cat", "a dog": "dog"}) >>> project.autolabel_job(job["jobId"])["status"] """ + if preserve_existing_annotations is not None: + warnings.warn( + "preserve_existing_annotations is deprecated and will be removed in a future release; " + 'use existing_annotations="preserve" instead.', + DeprecationWarning, + stacklevel=2, + ) + if existing_annotations is None and preserve_existing_annotations: + existing_annotations = "preserve" wire_model_type, model_options = _resolve_autolabel_model(model, model_type, model_options) return rfapi.start_autolabel_job( self.__api_key, @@ -1440,8 +1451,7 @@ def autolabel( run_nms=run_nms, reviewer_email=reviewer_email, model_options=model_options, - preserve_existing_annotations=preserve_existing_annotations, - merge_existing_annotations=merge_existing_annotations, + existing_annotations=existing_annotations, ) def autolabel_job(self, job_id: str) -> Dict: diff --git a/roboflow/util/autolabel_utils.py b/roboflow/util/autolabel_utils.py index 097e733d..40009171 100644 --- a/roboflow/util/autolabel_utils.py +++ b/roboflow/util/autolabel_utils.py @@ -8,6 +8,7 @@ from typing import Any, Dict, Iterable, Optional, Tuple, Union MODEL_TYPES = ("foundational", "roboflow") +EXISTING_ANNOTATIONS_MODES = ("replace", "preserve", "merge") def image_payload(image: str) -> Dict[str, str]: @@ -74,6 +75,22 @@ def ontology_payload(ontology: Optional[Ontology]) -> Optional[Dict[str, str]]: return {name: name for name in ontology} +def existing_annotations_flags(mode: Optional[str]) -> Dict[str, bool]: + """Translate an ``existing_annotations`` mode into the API's two booleans. + + ``merge`` sends ``preserveExistingAnnotations`` too, the way the web app + does, so a server without merge keeps the annotations instead of replacing + them. ``replace`` is the server default and sends nothing. + """ + if mode is None or mode == "replace": + return {} + if mode == "preserve": + return {"preserveExistingAnnotations": True} + if mode == "merge": + return {"preserveExistingAnnotations": True, "mergeExistingAnnotations": True} + raise ValueError("existing_annotations must be 'replace', 'preserve' or 'merge'") + + def resolve_model( model: str, model_type: str, model_options: Optional[Dict[str, Any]] = None ) -> Tuple[str, Optional[Dict[str, Any]]]: diff --git a/tests/adapters/test_autolabel.py b/tests/adapters/test_autolabel.py index 8ad4bc56..c80ac564 100644 --- a/tests/adapters/test_autolabel.py +++ b/tests/adapters/test_autolabel.py @@ -81,8 +81,7 @@ def test_start_job_contract_full_payload(self, mock_post): run_nms=False, reviewer_email="reviewer@example.com", model_options={"modelId": "proj/3"}, - preserve_existing_annotations=True, - merge_existing_annotations=True, + existing_annotations="merge", ) self.assertEqual( mock_post.call_args.kwargs["json"], @@ -101,6 +100,14 @@ def test_start_job_contract_full_payload(self, mock_post): }, ) + @patch("roboflow.util.http.requests.post") + def test_unknown_existing_annotations_mode_is_rejected_before_the_request(self, mock_post): + with self.assertRaises(ValueError): + rfapi.start_autolabel_job( + "key", "ws", "proj", batch_id="batch-1", model_type="sam3-rle", existing_annotations="keep" + ) + mock_post.assert_not_called() + @patch("roboflow.util.http.requests.post") def test_errors_raise_roboflow_error_with_status(self, mock_post): mock_post.return_value = _response({"error": {"message": "batch not found"}}, status_code=404) diff --git a/tests/cli/test_autolabel_handler.py b/tests/cli/test_autolabel_handler.py index 2c798991..e2b6e3b6 100644 --- a/tests/cli/test_autolabel_handler.py +++ b/tests/cli/test_autolabel_handler.py @@ -198,31 +198,42 @@ def test_foundational_start(self, _resolve, mock_api): run_nms=False, reviewer_email="r@example.com", model_options={"outputFormat": "polygon"}, - preserve_existing_annotations=None, - merge_existing_annotations=None, + existing_annotations=None, ) @patch("roboflow.adapters.rfapi.start_autolabel_job", return_value={"jobId": "job-1"}) @patch(_RESOLVE_PROJECT, return_value=("key", "ws", "proj")) - def test_preserve_existing_flag(self, _resolve, mock_api): - # The server default replaces annotations already on the batch images. + def test_legacy_preserve_existing_flag(self, _resolve, mock_api): + # Hidden alias of --existing-annotations preserve, kept for scripts written against 1.7. result = runner.invoke( app, ["autolabel", "start", "-p", "ws/proj", "--batch-id", "batch-1", "-m", "sam3-rle", "--preserve-existing"], ) self.assertEqual(result.exit_code, 0, result.output) - self.assertIs(mock_api.call_args.kwargs["preserve_existing_annotations"], True) + self.assertEqual(mock_api.call_args.kwargs["existing_annotations"], "preserve") @patch("roboflow.adapters.rfapi.start_autolabel_job", return_value={"jobId": "job-1"}) @patch(_RESOLVE_PROJECT, return_value=("key", "ws", "proj")) - def test_merge_existing_flag(self, _resolve, mock_api): + def test_existing_annotations_option(self, _resolve, mock_api): result = runner.invoke( app, - ["autolabel", "start", "-p", "ws/proj", "--batch-id", "batch-1", "-m", "sam3-rle", "--merge-existing"], + ["autolabel", "start", "-p", "ws/proj", "--batch-id", "batch-1", "-m", "sam3-rle"] + + ["--existing-annotations", "merge"], ) self.assertEqual(result.exit_code, 0, result.output) - self.assertIs(mock_api.call_args.kwargs["merge_existing_annotations"], True) - self.assertIsNone(mock_api.call_args.kwargs["preserve_existing_annotations"]) + self.assertEqual(mock_api.call_args.kwargs["existing_annotations"], "merge") + + @patch("roboflow.util.http.requests.post") + @patch(_RESOLVE_PROJECT, return_value=("key", "ws", "proj")) + def test_unknown_existing_annotations_mode_is_an_error(self, _resolve, mock_post): + result = runner.invoke( + app, + ["autolabel", "start", "-p", "ws/proj", "--batch-id", "batch-1", "-m", "sam3-rle"] + + ["--existing-annotations", "keep"], + ) + self.assertNotEqual(result.exit_code, 0) + self.assertIn("existing_annotations must be", result.output) + mock_post.assert_not_called() @patch("roboflow.adapters.rfapi.start_autolabel_job", return_value={"jobId": "job-1"}) @patch(_RESOLVE_PROJECT, return_value=("key", "ws", "proj")) diff --git a/tests/test_project_autolabel.py b/tests/test_project_autolabel.py index c9665244..8db42f44 100644 --- a/tests/test_project_autolabel.py +++ b/tests/test_project_autolabel.py @@ -31,21 +31,30 @@ def test_autolabel_foundational_passes_model_as_is(self, mock_start): run_nms=None, reviewer_email="reviewer@example.com", model_options=None, - preserve_existing_annotations=None, - merge_existing_annotations=None, + existing_annotations=None, ) @patch("roboflow.adapters.rfapi.start_autolabel_job", return_value={"jobId": "job-1"}) - def test_autolabel_forwards_preserve_existing_annotations(self, mock_start): - self.project.autolabel("batch-1", "sam3-rle", preserve_existing_annotations=True) + def test_autolabel_forwards_existing_annotations(self, mock_start): + self.project.autolabel("batch-1", "sam3-rle", existing_annotations="merge") - self.assertIs(mock_start.call_args.kwargs["preserve_existing_annotations"], True) + self.assertEqual(mock_start.call_args.kwargs["existing_annotations"], "merge") @patch("roboflow.adapters.rfapi.start_autolabel_job", return_value={"jobId": "job-1"}) - def test_autolabel_forwards_merge_existing_annotations(self, mock_start): - self.project.autolabel("batch-1", "sam3-rle", merge_existing_annotations=True) + def test_deprecated_preserve_existing_annotations_still_preserves(self, mock_start): + with self.assertWarns(DeprecationWarning): + self.project.autolabel("batch-1", "sam3-rle", preserve_existing_annotations=True) - self.assertIs(mock_start.call_args.kwargs["merge_existing_annotations"], True) + self.assertEqual(mock_start.call_args.kwargs["existing_annotations"], "preserve") + + @patch("roboflow.adapters.rfapi.start_autolabel_job", return_value={"jobId": "job-1"}) + def test_existing_annotations_wins_over_the_deprecated_flag(self, mock_start): + with self.assertWarns(DeprecationWarning): + self.project.autolabel( + "batch-1", "sam3-rle", preserve_existing_annotations=True, existing_annotations="merge" + ) + + self.assertEqual(mock_start.call_args.kwargs["existing_annotations"], "merge") @patch("roboflow.adapters.rfapi.start_autolabel_job", return_value={"jobId": "job-1"}) def test_autolabel_roboflow_model_is_sent_as_custom_roboflow(self, mock_start): diff --git a/tests/util/test_autolabel_utils.py b/tests/util/test_autolabel_utils.py index 59d0b44d..b52c632a 100644 --- a/tests/util/test_autolabel_utils.py +++ b/tests/util/test_autolabel_utils.py @@ -6,7 +6,7 @@ import unittest from unittest.mock import patch -from roboflow.util.autolabel_utils import image_payload, ontology_payload, resolve_model +from roboflow.util.autolabel_utils import existing_annotations_flags, image_payload, ontology_payload, resolve_model class TestImagePayload(unittest.TestCase): @@ -113,3 +113,22 @@ def test_roboflow_model_rides_in_model_options(self): def test_unknown_model_type_raises(self): with self.assertRaises(ValueError): resolve_model("x", "hosted") + + +class TestExistingAnnotationsFlags(unittest.TestCase): + def test_replace_is_the_server_default_and_sends_nothing(self): + self.assertEqual(existing_annotations_flags(None), {}) + self.assertEqual(existing_annotations_flags("replace"), {}) + + def test_preserve(self): + self.assertEqual(existing_annotations_flags("preserve"), {"preserveExistingAnnotations": True}) + + def test_merge_also_sends_preserve(self): + self.assertEqual( + existing_annotations_flags("merge"), + {"preserveExistingAnnotations": True, "mergeExistingAnnotations": True}, + ) + + def test_unknown_mode_is_rejected(self): + with self.assertRaises(ValueError): + existing_annotations_flags("keep") From e56fe513cf524f28bf71dc206d7d0981cf7ada11 Mon Sep 17 00:00:00 2001 From: Iuri de Silvio Date: Wed, 28 Oct 2026 20:22:01 +0100 Subject: [PATCH 3/3] Reject an empty --existing-annotations and match the deprecation hint to the value An empty --existing-annotations (ex: an unset $MODE) now fails like any other unknown mode instead of falling back to replace. preserve_existing_annotations=False now points to existing_annotations="replace", not "preserve". Co-Authored-By: Claude Opus 5.5 --- roboflow/cli/handlers/autolabel.py | 6 +++++- roboflow/core/project.py | 3 ++- tests/cli/test_autolabel_handler.py | 17 ++++++++++------- tests/test_project_autolabel.py | 9 ++++++++- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/roboflow/cli/handlers/autolabel.py b/roboflow/cli/handlers/autolabel.py index 6f02ea28..c9f6dab5 100644 --- a/roboflow/cli/handlers/autolabel.py +++ b/roboflow/cli/handlers/autolabel.py @@ -143,7 +143,11 @@ def start_job(key: str, workspace: str, proj: str) -> Any: run_nms=False if no_nms else None, reviewer_email=reviewer, model_options=wire_options, - existing_annotations=existing_annotations or ("preserve" if preserve_existing else None), + existing_annotations=( + existing_annotations + if existing_annotations is not None + else ("preserve" if preserve_existing else None) + ), ) _project_command(args, start_job) diff --git a/roboflow/core/project.py b/roboflow/core/project.py index d6a8394e..90001db0 100644 --- a/roboflow/core/project.py +++ b/roboflow/core/project.py @@ -1429,9 +1429,10 @@ def autolabel( >>> project.autolabel_job(job["jobId"])["status"] """ if preserve_existing_annotations is not None: + replacement = "preserve" if preserve_existing_annotations else "replace" warnings.warn( "preserve_existing_annotations is deprecated and will be removed in a future release; " - 'use existing_annotations="preserve" instead.', + f'use existing_annotations="{replacement}" instead.', DeprecationWarning, stacklevel=2, ) diff --git a/tests/cli/test_autolabel_handler.py b/tests/cli/test_autolabel_handler.py index e2b6e3b6..37cf4b21 100644 --- a/tests/cli/test_autolabel_handler.py +++ b/tests/cli/test_autolabel_handler.py @@ -226,13 +226,16 @@ def test_existing_annotations_option(self, _resolve, mock_api): @patch("roboflow.util.http.requests.post") @patch(_RESOLVE_PROJECT, return_value=("key", "ws", "proj")) def test_unknown_existing_annotations_mode_is_an_error(self, _resolve, mock_post): - result = runner.invoke( - app, - ["autolabel", "start", "-p", "ws/proj", "--batch-id", "batch-1", "-m", "sam3-rle"] - + ["--existing-annotations", "keep"], - ) - self.assertNotEqual(result.exit_code, 0) - self.assertIn("existing_annotations must be", result.output) + # An empty value (ex: an unset $MODE in a script) must fail too, not fall back to replace. + for mode in ("keep", ""): + with self.subTest(mode=mode): + result = runner.invoke( + app, + ["autolabel", "start", "-p", "ws/proj", "--batch-id", "batch-1", "-m", "sam3-rle"] + + ["--existing-annotations", mode], + ) + self.assertNotEqual(result.exit_code, 0) + self.assertIn("existing_annotations must be", result.output) mock_post.assert_not_called() @patch("roboflow.adapters.rfapi.start_autolabel_job", return_value={"jobId": "job-1"}) diff --git a/tests/test_project_autolabel.py b/tests/test_project_autolabel.py index 8db42f44..3f1290d3 100644 --- a/tests/test_project_autolabel.py +++ b/tests/test_project_autolabel.py @@ -42,11 +42,18 @@ def test_autolabel_forwards_existing_annotations(self, mock_start): @patch("roboflow.adapters.rfapi.start_autolabel_job", return_value={"jobId": "job-1"}) def test_deprecated_preserve_existing_annotations_still_preserves(self, mock_start): - with self.assertWarns(DeprecationWarning): + with self.assertWarnsRegex(DeprecationWarning, 'existing_annotations="preserve"'): self.project.autolabel("batch-1", "sam3-rle", preserve_existing_annotations=True) self.assertEqual(mock_start.call_args.kwargs["existing_annotations"], "preserve") + @patch("roboflow.adapters.rfapi.start_autolabel_job", return_value={"jobId": "job-1"}) + def test_deprecated_preserve_false_points_to_replace(self, mock_start): + with self.assertWarnsRegex(DeprecationWarning, 'existing_annotations="replace"'): + self.project.autolabel("batch-1", "sam3-rle", preserve_existing_annotations=False) + + self.assertIsNone(mock_start.call_args.kwargs["existing_annotations"]) + @patch("roboflow.adapters.rfapi.start_autolabel_job", return_value={"jobId": "job-1"}) def test_existing_annotations_wins_over_the_deprecated_flag(self, mock_start): with self.assertWarns(DeprecationWarning):