diff --git a/roboflow/adapters/rfapi.py b/roboflow/adapters/rfapi.py index e29b2b2e..528932c6 100644 --- a/roboflow/adapters/rfapi.py +++ b/roboflow/adapters/rfapi.py @@ -780,6 +780,7 @@ def save_annotation( is_prediction: bool = False, annotation_labelmap=None, overwrite: bool = False, + add_to_dataset: Optional[bool] = None, ): """ Upload an annotation to a specific project. @@ -787,10 +788,22 @@ def save_annotation( Args: annotation_path (str): path to annotation you'd like to upload image_id (str): image id you'd like to upload that has annotations for it. + add_to_dataset (bool | None): whether the annotated image joins the project's Dataset, + and so the next dataset version. `None` (the default) leaves the choice to the API, + which adds the image. Pass `False` to store the annotation without changing Dataset + membership — for example while the image is still waiting to be labeled or reviewed + in a batch. Prediction uploads never add the image to the Dataset. """ upload_url = _save_annotation_url( - api_key, project_url, annotation_name, image_id, job_name, is_prediction, overwrite + api_key, + project_url, + annotation_name, + image_id, + job_name, + is_prediction, + overwrite, + add_to_dataset, ) try: @@ -829,7 +842,9 @@ def save_annotation( return responsejson -def _save_annotation_url(api_key, project_url, name, image_id, job_name, is_prediction, overwrite=False): +def _save_annotation_url( + api_key, project_url, name, image_id, job_name, is_prediction, overwrite=False, add_to_dataset=None +): url = f"{API_URL}/dataset/{project_url}/annotate/{image_id}?api_key={api_key}&name={name}" if job_name: url += f"&jobName={job_name}" @@ -837,6 +852,9 @@ def _save_annotation_url(api_key, project_url, name, image_id, job_name, is_pred url += "&prediction=true" if overwrite: url += "&overwrite=true" + # Omitted means "whatever the API does by default", which today is to add the image. + if add_to_dataset is not None: + url += f"&addToDataset={'true' if add_to_dataset else 'false'}" return url diff --git a/roboflow/core/project.py b/roboflow/core/project.py index f9fe8fb5..03dc0ab6 100644 --- a/roboflow/core/project.py +++ b/roboflow/core/project.py @@ -553,7 +553,18 @@ def save_annotation( is_prediction: bool = False, annotation_overwrite=False, num_retry_uploads=0, + add_to_dataset: Optional[bool] = None, ): + """ + Upload an annotation for an image that is already in the project. + + Args: + add_to_dataset (bool | None): whether the annotated image joins the project's + Dataset, and so the next dataset version. `None` (the default) leaves the + choice to the API, which adds the image. Pass `False` to store the annotation + without changing Dataset membership — for example while the image is still + waiting to be labeled or reviewed in a batch. + """ project_url = self.id.rsplit("/")[1] annotation_name, annotation_str = self._annotation_params(annotation_path) t0 = time.time() @@ -571,6 +582,7 @@ def save_annotation( is_prediction=is_prediction, annotation_labelmap=annotation_labelmap, overwrite=annotation_overwrite, + add_to_dataset=add_to_dataset, ) upload_retry_attempts = retry.retries except AnnotationSaveError as e: @@ -597,6 +609,7 @@ def single_upload( sequence_number=None, sequence_size=None, metadata: Optional[Dict] = None, + add_to_dataset: Optional[bool] = None, **kwargs, ): if tag_names is None: @@ -637,6 +650,7 @@ def single_upload( is_prediction, annotation_overwrite, num_retry_uploads=num_retry_uploads, + add_to_dataset=add_to_dataset, ) return { diff --git a/tests/test_rfapi.py b/tests/test_rfapi.py index d0106312..44542740 100644 --- a/tests/test_rfapi.py +++ b/tests/test_rfapi.py @@ -8,6 +8,7 @@ from roboflow.adapters.rfapi import ( RoboflowError, + _save_annotation_url, create_training_v2, delete_version_training, get_train_recipe, @@ -434,3 +435,45 @@ def test_restore_trash_item_restores_a_training(self): if __name__ == "__main__": unittest.main() + + +class TestSaveAnnotationUrl(unittest.TestCase): + API_KEY = "test_api_key" + PROJECT_URL = "test_project" + IMAGE_ID = "test_image_id" + ANNOTATION_NAME = "annotation.json" + + def _url(self, **kwargs): + return _save_annotation_url( + self.API_KEY, + self.PROJECT_URL, + self.ANNOTATION_NAME, + self.IMAGE_ID, + kwargs.pop("job_name", None), + kwargs.pop("is_prediction", False), + **kwargs, + ) + + def test_add_to_dataset_is_omitted_by_default(self): + # Omitting the parameter leaves the choice to the API, which adds the image to the + # Dataset. Sending it unasked would silently change what existing callers upload. + self.assertNotIn("addToDataset", self._url()) + + def test_add_to_dataset_false_opts_out(self): + self.assertIn("addToDataset=false", self._url(add_to_dataset=False)) + + def test_add_to_dataset_true_is_explicit(self): + self.assertIn("addToDataset=true", self._url(add_to_dataset=True)) + + def test_add_to_dataset_composes_with_the_other_parameters(self): + url = self._url( + job_name="my-job", + is_prediction=True, + overwrite=True, + add_to_dataset=False, + ) + + self.assertIn("jobName=my-job", url) + self.assertIn("prediction=true", url) + self.assertIn("overwrite=true", url) + self.assertIn("addToDataset=false", url)