Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@ All notable changes to this project will be documented in this file.
project is labeled with a trained keypoint model. Points below it are saved as
not visible; omitted, the server default (0.6) applies
([#571](https://github.com/roboflow/roboflow-python/pull/571)).
- `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
([#570](https://github.com/roboflow/roboflow-python/pull/570)).

### 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`
([#570](https://github.com/roboflow/roboflow-python/pull/570)).

## 1.7.3

Expand Down
11 changes: 7 additions & 4 deletions CLI-COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ roboflow autolabel preview -p my-project -m sam3-rle --image https://example.com
roboflow autolabel start -p my-project --batch-id <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 <batch-id> -m my-project/3 --model-type roboflow
roboflow autolabel start -p my-project --batch-id <batch-id> -m sam3-rle --preserve-existing
roboflow autolabel start -p my-project --batch-id <batch-id> -m sam3-rle --existing-annotations merge
roboflow autolabel start -p my-keypoints --batch-id <batch-id> -m my-keypoints/2 --model-type roboflow \
--keypoint-confidence 0.7
roboflow autolabel job <job-id>
Expand All @@ -335,9 +335,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
only adds new ones. Keypoint-detection projects are labeled with a trained
`--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. Keypoint-detection projects are labeled with a trained
keypoint model; `--keypoint-confidence` sets the lowest confidence a keypoint
needs to be saved as visible (server default 0.6). `job` looks the id up in
your default workspace, so when the job was started with a `workspace/project`
Expand Down
18 changes: 11 additions & 7 deletions roboflow/adapters/rfapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -1541,7 +1542,7 @@ def start_autolabel_job(
run_nms=None,
reviewer_email=None,
model_options=None,
preserve_existing_annotations=None,
existing_annotations=None,
keypoint_confidence_threshold=None,
):
"""Start a hosted auto-label job over a batch.
Expand All @@ -1554,12 +1555,15 @@ 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. ``keypoint_confidence_threshold`` (0-1, keypoint-detection
projects only) is the lowest confidence a predicted keypoint needs to be
saved as visible; the server default is 0.6. Returns
to the whole batch. ``existing_annotations`` is ``"replace"`` (the server
default), ``"preserve"`` or ``"merge"``; see ``Project.autolabel``.
``keypoint_confidence_threshold`` (0-1, keypoint-detection projects only)
is the lowest confidence a predicted keypoint needs to be saved as
visible; the server default is 0.6. Returns
``{jobId, annotationJobId, message}``.

Raises:
ValueError: ``existing_annotations`` is not one of those modes.
"""
payload = {"batchId": batch_id, "modelType": model_type}
optional = {
Expand All @@ -1570,10 +1574,10 @@ def start_autolabel_job(
"runNMS": run_nms,
"reviewerEmail": reviewer_email,
"modelOptions": model_options,
"preserveExistingAnnotations": preserve_existing_annotations,
"keypointConfidenceThreshold": keypoint_confidence_threshold,
}
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},
Expand Down
21 changes: 15 additions & 6 deletions roboflow/cli/handlers/autolabel.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +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",
),
] = None,
preserve_existing: Annotated[
bool,
typer.Option("--preserve-existing", hidden=True, help="Same as --existing-annotations preserve"),
] = False,
keypoint_confidence: Annotated[
Optional[float],
Expand Down Expand Up @@ -146,7 +151,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,
preserve_existing_annotations=True if preserve_existing else None,
existing_annotations=(
existing_annotations
if existing_annotations is not None
else ("preserve" if preserve_existing else None)
),
keypoint_confidence_threshold=keypoint_confidence,
)

Expand Down
28 changes: 23 additions & 5 deletions roboflow/core/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -1374,6 +1374,7 @@ def autolabel(
reviewer_email: Optional[str] = None,
model_options: Optional[Dict] = None,
preserve_existing_annotations: Optional[bool] = None,
existing_annotations: Optional[str] = None,
keypoint_confidence_threshold: Optional[float] = None,
) -> Dict:
"""Start a hosted auto-label job over a batch of images.
Expand Down Expand Up @@ -1407,10 +1408,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.
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. Use
``"preserve"`` or ``"merge"`` when the batch contains images
that were already labeled or reviewed.
keypoint_confidence_threshold: Keypoint-detection projects only,
labeled with a trained keypoint model
(``model_type="roboflow"``). The lowest confidence, from 0 to
Expand All @@ -1428,6 +1436,16 @@ def autolabel(
... ontology={"a cat": "cat", "a dog": "dog"})
>>> 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; "
f'use existing_annotations="{replacement}" 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,
Expand All @@ -1442,7 +1460,7 @@ def autolabel(
run_nms=run_nms,
reviewer_email=reviewer_email,
model_options=model_options,
preserve_existing_annotations=preserve_existing_annotations,
existing_annotations=existing_annotations,
keypoint_confidence_threshold=keypoint_confidence_threshold,
)

Expand Down
17 changes: 17 additions & 0 deletions roboflow/util/autolabel_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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]]]:
Expand Down
11 changes: 10 additions & 1 deletion tests/adapters/test_autolabel.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +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,
existing_annotations="merge",
keypoint_confidence_threshold=0.7,
)
self.assertEqual(
Expand All @@ -97,10 +97,19 @@ def test_start_job_contract_full_payload(self, mock_post):
"reviewerEmail": "reviewer@example.com",
"modelOptions": {"modelId": "proj/3"},
"preserveExistingAnnotations": True,
"mergeExistingAnnotations": True,
"keypointConfidenceThreshold": 0.7,
},
)

@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)
Expand Down
34 changes: 30 additions & 4 deletions tests/cli/test_autolabel_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,20 +198,46 @@ def test_foundational_start(self, _resolve, mock_api):
run_nms=False,
reviewer_email="r@example.com",
model_options={"outputFormat": "polygon"},
preserve_existing_annotations=None,
existing_annotations=None,
keypoint_confidence_threshold=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_existing_annotations_option(self, _resolve, mock_api):
result = runner.invoke(
app,
["autolabel", "start", "-p", "ws/proj", "--batch-id", "batch-1", "-m", "sam3-rle"]
+ ["--existing-annotations", "merge"],
)
self.assertEqual(result.exit_code, 0, result.output)
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):
# 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"})
@patch(_RESOLVE_PROJECT, return_value=("key", "ws", "proj"))
Expand Down
31 changes: 27 additions & 4 deletions tests/test_project_autolabel.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,38 @@ 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,
existing_annotations=None,
keypoint_confidence_threshold=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_deprecated_preserve_existing_annotations_still_preserves(self, mock_start):
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):
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_forwards_keypoint_confidence_threshold(self, mock_start):
Expand Down
21 changes: 20 additions & 1 deletion tests/util/test_autolabel_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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")
Loading