From acaab2fe880e48fad46749ffd3a421d5841b36e4 Mon Sep 17 00:00:00 2001 From: Rodrigo Barbosa Date: Tue, 24 Feb 2026 13:12:35 +0000 Subject: [PATCH 1/5] upload metadata sdk --- roboflow/adapters/rfapi.py | 29 +++++++++++++++------- roboflow/core/project.py | 9 +++++++ roboflow/roboflowpy.py | 8 ++++++ tests/test_rfapi.py | 51 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 9 deletions(-) mode change 100755 => 100644 roboflow/roboflowpy.py diff --git a/roboflow/adapters/rfapi.py b/roboflow/adapters/rfapi.py index 4c29ed34..a4bf6a83 100644 --- a/roboflow/adapters/rfapi.py +++ b/roboflow/adapters/rfapi.py @@ -209,6 +209,7 @@ def upload_image( tag_names: Optional[List[str]] = None, sequence_number: Optional[int] = None, sequence_size: Optional[int] = None, + metadata: Optional[Dict] = None, **kwargs, ): """ @@ -218,6 +219,8 @@ def upload_image( image_path (str): path to image you'd like to upload hosted_image (bool): whether the image is hosted on Roboflow split (str): the dataset split the image to + metadata (dict, optional): custom key-value metadata to attach to the image. + Example: {"camera_id": "cam001", "location": "warehouse"} """ coalesced_batch_name = batch_name or DEFAULT_BATCH_NAME @@ -232,13 +235,14 @@ def upload_image( upload_url = _local_upload_url( api_key, project_url, coalesced_batch_name, tag_names, sequence_number, sequence_size, kwargs ) - m = MultipartEncoder( - fields={ - "name": image_name, - "split": split, - "file": ("imageToUpload", imgjpeg, "image/jpeg"), - } - ) + fields = { + "name": image_name, + "split": split, + "file": ("imageToUpload", imgjpeg, "image/jpeg"), + } + if metadata is not None: + fields["metadata"] = json.dumps(metadata) + m = MultipartEncoder(fields=fields) try: response = requests.post(upload_url, data=m, headers={"Content-Type": m.content_type}, timeout=(300, 300)) @@ -247,7 +251,12 @@ def upload_image( else: # Hosted image upload url - upload_url = _hosted_upload_url(api_key, project_url, image_path, split, coalesced_batch_name, tag_names) + hosted_kwargs = dict(kwargs) + if metadata is not None: + hosted_kwargs["metadata"] = json.dumps(metadata) + upload_url = _hosted_upload_url( + api_key, project_url, image_path, split, coalesced_batch_name, tag_names, hosted_kwargs + ) try: # Get response @@ -363,7 +372,8 @@ def _upload_url(api_key, project_url, **kwargs): return url -def _hosted_upload_url(api_key, project_url, image_path, split, batch_name, tag_names): +def _hosted_upload_url(api_key, project_url, image_path, split, batch_name, tag_names, kwargs=None): + extra = kwargs or {} return _upload_url( api_key, project_url, @@ -372,6 +382,7 @@ def _hosted_upload_url(api_key, project_url, image_path, split, batch_name, tag_ image=image_path, batch=batch_name, tag=tag_names, + **extra, ) diff --git a/roboflow/core/project.py b/roboflow/core/project.py index ab96fb27..f672bf6b 100644 --- a/roboflow/core/project.py +++ b/roboflow/core/project.py @@ -389,6 +389,7 @@ def upload( batch_name: Optional[str] = None, tag_names: Optional[List[str]] = None, is_prediction: bool = False, + metadata: Optional[Dict] = None, **kwargs, ): """ @@ -405,6 +406,8 @@ def upload( batch_name (str): name of batch to upload to within project tag_names (list[str]): tags to be applied to an image is_prediction (bool): whether the annotation data is a prediction rather than ground truth + metadata (dict, optional): custom key-value metadata to attach to the image. + Example: {"camera_id": "cam001", "location": "warehouse"} Example: >>> import roboflow @@ -450,6 +453,7 @@ def upload( batch_name=batch_name, tag_names=tag_names, is_prediction=is_prediction, + metadata=metadata, **kwargs, ) @@ -468,6 +472,7 @@ def upload( batch_name=batch_name, tag_names=tag_names, is_prediction=is_prediction, + metadata=metadata, **kwargs, ) print("[ " + path + " ] was uploaded succesfully.") @@ -485,6 +490,7 @@ def upload_image( tag_names: Optional[List[str]] = None, sequence_number=None, sequence_size=None, + metadata: Optional[Dict] = None, **kwargs, ): project_url = self.id.rsplit("/")[1] @@ -508,6 +514,7 @@ def upload_image( tag_names=tag_names, sequence_number=sequence_number, sequence_size=sequence_size, + metadata=metadata, **kwargs, ) upload_retry_attempts = retry.retries @@ -571,6 +578,7 @@ def single_upload( annotation_overwrite=False, sequence_number=None, sequence_size=None, + metadata: Optional[Dict] = None, **kwargs, ): if tag_names is None: @@ -597,6 +605,7 @@ def single_upload( tag_names, sequence_number, sequence_size, + metadata=metadata, **kwargs, ) image_id = uploaded_image["id"] # type: ignore[index] diff --git a/roboflow/roboflowpy.py b/roboflow/roboflowpy.py old mode 100755 new mode 100644 index f68bda47..ccf8aa48 --- a/roboflow/roboflowpy.py +++ b/roboflow/roboflowpy.py @@ -72,6 +72,7 @@ def upload_image(args): rf = roboflow.Roboflow() workspace = rf.workspace(args.workspace) project = workspace.project(args.project) + metadata = json.loads(args.metadata) if args.metadata else None project.single_upload( image_path=args.imagefile, annotation_path=args.annotation, @@ -81,6 +82,7 @@ def upload_image(args): batch_name=args.batch, tag_names=args.tag_names.split(",") if args.tag_names else [], is_prediction=args.is_prediction, + metadata=metadata, ) @@ -333,6 +335,12 @@ def _add_upload_parser(subparsers): help="Whether this upload is a prediction (optional)", action="store_true", ) + upload_parser.add_argument( + "-M", + "--metadata", + dest="metadata", + help='JSON string of metadata to attach to the image (e.g. \'{"camera_id":"cam001"}\')', + ) upload_parser.set_defaults(func=upload_image) diff --git a/tests/test_rfapi.py b/tests/test_rfapi.py index ad92210f..77b7c83c 100644 --- a/tests/test_rfapi.py +++ b/tests/test_rfapi.py @@ -1,3 +1,4 @@ +import json import os import unittest import urllib @@ -121,6 +122,56 @@ def test_upload_image_hosted(self): result = upload_image(self.API_KEY, self.PROJECT_URL, self.IMAGE_PATH_HOSTED, **upload_image_payload) self.assertTrue(result["success"], msg=f"Failed in scenario: {scenario['desc']}") + @responses.activate + @patch("roboflow.util.image_utils.file2jpeg") + def test_upload_image_local_with_metadata(self, mock_file2jpeg): + mock_file2jpeg.return_value = b"image_data" + + metadata = {"camera_id": "cam001", "location": "warehouse"} + expected_url = ( + f"{API_URL}/dataset/{self.PROJECT_URL}/upload?" + f"api_key={self.API_KEY}&batch={urllib.parse.quote_plus(DEFAULT_BATCH_NAME)}" + f"&tag=lonely-tag" + ) + responses.add(responses.POST, expected_url, json={"success": True}, status=200) + + result = upload_image( + self.API_KEY, + self.PROJECT_URL, + self.IMAGE_PATH_LOCAL, + tag_names=self.TAG_NAMES_LOCAL, + metadata=metadata, + ) + self.assertTrue(result["success"]) + + # Verify metadata was sent as a multipart field + request_body = responses.calls[0].request.body + self.assertIn(b'"camera_id"', request_body) + self.assertIn(b'"warehouse"', request_body) + + @responses.activate + def test_upload_image_hosted_with_metadata(self): + metadata = {"camera_id": "cam001", "location": "warehouse"} + metadata_encoded = urllib.parse.quote_plus(json.dumps(metadata)) + expected_url = ( + f"{API_URL}/dataset/{self.PROJECT_URL}/upload?" + f"api_key={self.API_KEY}&name={self.IMAGE_NAME_HOSTED}" + f"&split=train&image={urllib.parse.quote_plus(self.IMAGE_PATH_HOSTED)}" + f"&batch={urllib.parse.quote_plus(DEFAULT_BATCH_NAME)}" + f"&tag=tag1&tag=tag2&metadata={metadata_encoded}" + ) + responses.add(responses.POST, expected_url, json={"success": True}, status=200) + + result = upload_image( + self.API_KEY, + self.PROJECT_URL, + self.IMAGE_PATH_HOSTED, + hosted_image=True, + tag_names=self.TAG_NAMES_HOSTED, + metadata=metadata, + ) + self.assertTrue(result["success"]) + def _reset_responses(self): responses.reset() From fd0f5f5969b503adcae885e3a1b5b3333dbc8a97 Mon Sep 17 00:00:00 2001 From: Rodrigo Barbosa Date: Tue, 24 Feb 2026 13:14:55 +0000 Subject: [PATCH 2/5] fixing the local usage --- tests/manual/uselocal | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/manual/uselocal b/tests/manual/uselocal index 8c8bf9ca..b91bcbc3 100644 --- a/tests/manual/uselocal +++ b/tests/manual/uselocal @@ -1,8 +1,8 @@ #!/bin/env bash SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" cp $SCRIPT_DIR/data/.config-staging $SCRIPT_DIR/data/.config -export API_URL=https://localhost.roboflow.one -export APP_URL=https://localhost.roboflow.one +export API_URL=https://localapi.roboflow.one +export APP_URL=https://localapp.roboflow.one export DEDICATED_DEPLOYMENT_URL=https://staging.roboflow.cloud export ROBOFLOW_CONFIG_DIR=$SCRIPT_DIR/data/.config # need to set it in /etc/hosts to the IP of host.docker.internal! From 993a0a1a3a6e1e24b88e1463d19ae8429809b932 Mon Sep 17 00:00:00 2001 From: Rodrigo Barbosa Date: Tue, 24 Feb 2026 13:30:47 +0000 Subject: [PATCH 3/5] quick fix --- roboflow/core/project.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/roboflow/core/project.py b/roboflow/core/project.py index f672bf6b..e34c4ade 100644 --- a/roboflow/core/project.py +++ b/roboflow/core/project.py @@ -423,6 +423,8 @@ def upload( tag_names = [] is_hosted = image_path.startswith("http://") or image_path.startswith("https://") + if is_hosted: + hosted_image = True is_file = os.path.isfile(image_path) or is_hosted is_dir = os.path.isdir(image_path) From d362ea79d6d6504777ebf280646450dbf5f3f6d3 Mon Sep 17 00:00:00 2001 From: Rodrigo Barbosa Date: Tue, 24 Feb 2026 14:22:12 +0000 Subject: [PATCH 4/5] fix permission --- roboflow/roboflowpy.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 roboflow/roboflowpy.py diff --git a/roboflow/roboflowpy.py b/roboflow/roboflowpy.py old mode 100644 new mode 100755 From fb4fa8b534204dead3c9a015eaacba0c082d2c30 Mon Sep 17 00:00:00 2001 From: Rodrigo Barbosa Date: Tue, 24 Feb 2026 14:29:19 +0000 Subject: [PATCH 5/5] Docs updates and version bump --- CLI-COMMANDS.md | 20 ++++++++++++++++++++ docs/index.md | 26 ++++++++++++++++++++++++++ roboflow/__init__.py | 2 +- 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/CLI-COMMANDS.md b/CLI-COMMANDS.md index 771330ae..1d24c277 100644 --- a/CLI-COMMANDS.md +++ b/CLI-COMMANDS.md @@ -126,6 +126,26 @@ Uploading to existing project my-workspace/my-chess [UPLOADED] /home/jonny/tmp/chess/112_jpg.rf.1a6e7b87410fa3f787f10e82bd02b54e.jpg (7tWtAn573cKrefeg5pIO) / annotations = OK ``` +## Example: upload a single image + +Upload a single image to a project, optionally with annotations, tags, and metadata: + +```bash +roboflow upload image.jpg -p my-project -s train +``` + +Upload with custom metadata (JSON string): + +```bash +roboflow upload image.jpg -p my-project -M '{"camera_id":"cam001","location":"warehouse-3"}' +``` + +Upload with annotation and tags: + +```bash +roboflow upload image.jpg -p my-project -a annotation.xml -t "outdoor,daytime" -s valid +``` + ## Example: list workspaces List the workspaces you have access to diff --git a/docs/index.md b/docs/index.md index 04afb444..805647d7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -126,6 +126,32 @@ Or from the CLI: roboflow search-export "class:person" -f coco -d my-project -l ./my-export ``` +### Upload with Metadata + +Attach custom key-value metadata to images during upload: + +```python +project = workspace.project("my-project") + +# Upload a local image with metadata +project.upload( + image_path="./image.jpg", + metadata={"camera_id": "cam001", "location": "warehouse-3"}, +) + +# Upload a hosted image with metadata +project.upload( + image_path="https://example.com/image.jpg", + metadata={"camera_id": "cam002", "shift": "night"}, +) +``` + +Or from the CLI: + +```bash +roboflow upload image.jpg -p my-project -M '{"camera_id":"cam001","location":"warehouse-3"}' +``` + ## Library Structure The Roboflow Python library is structured using the same Workspace, Project, and Version ontology that you will see in the Roboflow application. diff --git a/roboflow/__init__.py b/roboflow/__init__.py index 40a2c245..4d2323f4 100644 --- a/roboflow/__init__.py +++ b/roboflow/__init__.py @@ -15,7 +15,7 @@ from roboflow.models import CLIPModel, GazeModel # noqa: F401 from roboflow.util.general import write_line -__version__ = "1.2.14" +__version__ = "1.2.15" def check_key(api_key, model, notebook, num_retries=0):