Skip to content

Commit 1eb8238

Browse files
authored
Add key-value metadata support for image upload (DATAMAN-156) (#440)
* upload metadata sdk * fixing the local usage * quick fix * fix permission * Docs updates and version bump
1 parent f9efb7b commit 1eb8238

File tree

8 files changed

+139
-12
lines changed

8 files changed

+139
-12
lines changed

CLI-COMMANDS.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,26 @@ Uploading to existing project my-workspace/my-chess
126126
[UPLOADED] /home/jonny/tmp/chess/112_jpg.rf.1a6e7b87410fa3f787f10e82bd02b54e.jpg (7tWtAn573cKrefeg5pIO) / annotations = OK
127127
```
128128

129+
## Example: upload a single image
130+
131+
Upload a single image to a project, optionally with annotations, tags, and metadata:
132+
133+
```bash
134+
roboflow upload image.jpg -p my-project -s train
135+
```
136+
137+
Upload with custom metadata (JSON string):
138+
139+
```bash
140+
roboflow upload image.jpg -p my-project -M '{"camera_id":"cam001","location":"warehouse-3"}'
141+
```
142+
143+
Upload with annotation and tags:
144+
145+
```bash
146+
roboflow upload image.jpg -p my-project -a annotation.xml -t "outdoor,daytime" -s valid
147+
```
148+
129149
## Example: list workspaces
130150
List the workspaces you have access to
131151

docs/index.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,32 @@ Or from the CLI:
126126
roboflow search-export "class:person" -f coco -d my-project -l ./my-export
127127
```
128128

129+
### Upload with Metadata
130+
131+
Attach custom key-value metadata to images during upload:
132+
133+
```python
134+
project = workspace.project("my-project")
135+
136+
# Upload a local image with metadata
137+
project.upload(
138+
image_path="./image.jpg",
139+
metadata={"camera_id": "cam001", "location": "warehouse-3"},
140+
)
141+
142+
# Upload a hosted image with metadata
143+
project.upload(
144+
image_path="https://example.com/image.jpg",
145+
metadata={"camera_id": "cam002", "shift": "night"},
146+
)
147+
```
148+
149+
Or from the CLI:
150+
151+
```bash
152+
roboflow upload image.jpg -p my-project -M '{"camera_id":"cam001","location":"warehouse-3"}'
153+
```
154+
129155
## Library Structure
130156

131157
The Roboflow Python library is structured using the same Workspace, Project, and Version ontology that you will see in the Roboflow application.

roboflow/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from roboflow.models import CLIPModel, GazeModel # noqa: F401
1616
from roboflow.util.general import write_line
1717

18-
__version__ = "1.2.14"
18+
__version__ = "1.2.15"
1919

2020

2121
def check_key(api_key, model, notebook, num_retries=0):

roboflow/adapters/rfapi.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ def upload_image(
209209
tag_names: Optional[List[str]] = None,
210210
sequence_number: Optional[int] = None,
211211
sequence_size: Optional[int] = None,
212+
metadata: Optional[Dict] = None,
212213
**kwargs,
213214
):
214215
"""
@@ -218,6 +219,8 @@ def upload_image(
218219
image_path (str): path to image you'd like to upload
219220
hosted_image (bool): whether the image is hosted on Roboflow
220221
split (str): the dataset split the image to
222+
metadata (dict, optional): custom key-value metadata to attach to the image.
223+
Example: {"camera_id": "cam001", "location": "warehouse"}
221224
"""
222225

223226
coalesced_batch_name = batch_name or DEFAULT_BATCH_NAME
@@ -232,13 +235,14 @@ def upload_image(
232235
upload_url = _local_upload_url(
233236
api_key, project_url, coalesced_batch_name, tag_names, sequence_number, sequence_size, kwargs
234237
)
235-
m = MultipartEncoder(
236-
fields={
237-
"name": image_name,
238-
"split": split,
239-
"file": ("imageToUpload", imgjpeg, "image/jpeg"),
240-
}
241-
)
238+
fields = {
239+
"name": image_name,
240+
"split": split,
241+
"file": ("imageToUpload", imgjpeg, "image/jpeg"),
242+
}
243+
if metadata is not None:
244+
fields["metadata"] = json.dumps(metadata)
245+
m = MultipartEncoder(fields=fields)
242246

243247
try:
244248
response = requests.post(upload_url, data=m, headers={"Content-Type": m.content_type}, timeout=(300, 300))
@@ -247,7 +251,12 @@ def upload_image(
247251

248252
else:
249253
# Hosted image upload url
250-
upload_url = _hosted_upload_url(api_key, project_url, image_path, split, coalesced_batch_name, tag_names)
254+
hosted_kwargs = dict(kwargs)
255+
if metadata is not None:
256+
hosted_kwargs["metadata"] = json.dumps(metadata)
257+
upload_url = _hosted_upload_url(
258+
api_key, project_url, image_path, split, coalesced_batch_name, tag_names, hosted_kwargs
259+
)
251260

252261
try:
253262
# Get response
@@ -363,7 +372,8 @@ def _upload_url(api_key, project_url, **kwargs):
363372
return url
364373

365374

366-
def _hosted_upload_url(api_key, project_url, image_path, split, batch_name, tag_names):
375+
def _hosted_upload_url(api_key, project_url, image_path, split, batch_name, tag_names, kwargs=None):
376+
extra = kwargs or {}
367377
return _upload_url(
368378
api_key,
369379
project_url,
@@ -372,6 +382,7 @@ def _hosted_upload_url(api_key, project_url, image_path, split, batch_name, tag_
372382
image=image_path,
373383
batch=batch_name,
374384
tag=tag_names,
385+
**extra,
375386
)
376387

377388

roboflow/core/project.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,7 @@ def upload(
389389
batch_name: Optional[str] = None,
390390
tag_names: Optional[List[str]] = None,
391391
is_prediction: bool = False,
392+
metadata: Optional[Dict] = None,
392393
**kwargs,
393394
):
394395
"""
@@ -405,6 +406,8 @@ def upload(
405406
batch_name (str): name of batch to upload to within project
406407
tag_names (list[str]): tags to be applied to an image
407408
is_prediction (bool): whether the annotation data is a prediction rather than ground truth
409+
metadata (dict, optional): custom key-value metadata to attach to the image.
410+
Example: {"camera_id": "cam001", "location": "warehouse"}
408411
409412
Example:
410413
>>> import roboflow
@@ -420,6 +423,8 @@ def upload(
420423
tag_names = []
421424

422425
is_hosted = image_path.startswith("http://") or image_path.startswith("https://")
426+
if is_hosted:
427+
hosted_image = True
423428

424429
is_file = os.path.isfile(image_path) or is_hosted
425430
is_dir = os.path.isdir(image_path)
@@ -450,6 +455,7 @@ def upload(
450455
batch_name=batch_name,
451456
tag_names=tag_names,
452457
is_prediction=is_prediction,
458+
metadata=metadata,
453459
**kwargs,
454460
)
455461

@@ -468,6 +474,7 @@ def upload(
468474
batch_name=batch_name,
469475
tag_names=tag_names,
470476
is_prediction=is_prediction,
477+
metadata=metadata,
471478
**kwargs,
472479
)
473480
print("[ " + path + " ] was uploaded succesfully.")
@@ -485,6 +492,7 @@ def upload_image(
485492
tag_names: Optional[List[str]] = None,
486493
sequence_number=None,
487494
sequence_size=None,
495+
metadata: Optional[Dict] = None,
488496
**kwargs,
489497
):
490498
project_url = self.id.rsplit("/")[1]
@@ -508,6 +516,7 @@ def upload_image(
508516
tag_names=tag_names,
509517
sequence_number=sequence_number,
510518
sequence_size=sequence_size,
519+
metadata=metadata,
511520
**kwargs,
512521
)
513522
upload_retry_attempts = retry.retries
@@ -571,6 +580,7 @@ def single_upload(
571580
annotation_overwrite=False,
572581
sequence_number=None,
573582
sequence_size=None,
583+
metadata: Optional[Dict] = None,
574584
**kwargs,
575585
):
576586
if tag_names is None:
@@ -597,6 +607,7 @@ def single_upload(
597607
tag_names,
598608
sequence_number,
599609
sequence_size,
610+
metadata=metadata,
600611
**kwargs,
601612
)
602613
image_id = uploaded_image["id"] # type: ignore[index]

roboflow/roboflowpy.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ def upload_image(args):
7272
rf = roboflow.Roboflow()
7373
workspace = rf.workspace(args.workspace)
7474
project = workspace.project(args.project)
75+
metadata = json.loads(args.metadata) if args.metadata else None
7576
project.single_upload(
7677
image_path=args.imagefile,
7778
annotation_path=args.annotation,
@@ -81,6 +82,7 @@ def upload_image(args):
8182
batch_name=args.batch,
8283
tag_names=args.tag_names.split(",") if args.tag_names else [],
8384
is_prediction=args.is_prediction,
85+
metadata=metadata,
8486
)
8587

8688

@@ -333,6 +335,12 @@ def _add_upload_parser(subparsers):
333335
help="Whether this upload is a prediction (optional)",
334336
action="store_true",
335337
)
338+
upload_parser.add_argument(
339+
"-M",
340+
"--metadata",
341+
dest="metadata",
342+
help='JSON string of metadata to attach to the image (e.g. \'{"camera_id":"cam001"}\')',
343+
)
336344
upload_parser.set_defaults(func=upload_image)
337345

338346

tests/manual/uselocal

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
#!/bin/env bash
22
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
33
cp $SCRIPT_DIR/data/.config-staging $SCRIPT_DIR/data/.config
4-
export API_URL=https://localhost.roboflow.one
5-
export APP_URL=https://localhost.roboflow.one
4+
export API_URL=https://localapi.roboflow.one
5+
export APP_URL=https://localapp.roboflow.one
66
export DEDICATED_DEPLOYMENT_URL=https://staging.roboflow.cloud
77
export ROBOFLOW_CONFIG_DIR=$SCRIPT_DIR/data/.config
88
# need to set it in /etc/hosts to the IP of host.docker.internal!

tests/test_rfapi.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import os
23
import unittest
34
import urllib
@@ -121,6 +122,56 @@ def test_upload_image_hosted(self):
121122
result = upload_image(self.API_KEY, self.PROJECT_URL, self.IMAGE_PATH_HOSTED, **upload_image_payload)
122123
self.assertTrue(result["success"], msg=f"Failed in scenario: {scenario['desc']}")
123124

125+
@responses.activate
126+
@patch("roboflow.util.image_utils.file2jpeg")
127+
def test_upload_image_local_with_metadata(self, mock_file2jpeg):
128+
mock_file2jpeg.return_value = b"image_data"
129+
130+
metadata = {"camera_id": "cam001", "location": "warehouse"}
131+
expected_url = (
132+
f"{API_URL}/dataset/{self.PROJECT_URL}/upload?"
133+
f"api_key={self.API_KEY}&batch={urllib.parse.quote_plus(DEFAULT_BATCH_NAME)}"
134+
f"&tag=lonely-tag"
135+
)
136+
responses.add(responses.POST, expected_url, json={"success": True}, status=200)
137+
138+
result = upload_image(
139+
self.API_KEY,
140+
self.PROJECT_URL,
141+
self.IMAGE_PATH_LOCAL,
142+
tag_names=self.TAG_NAMES_LOCAL,
143+
metadata=metadata,
144+
)
145+
self.assertTrue(result["success"])
146+
147+
# Verify metadata was sent as a multipart field
148+
request_body = responses.calls[0].request.body
149+
self.assertIn(b'"camera_id"', request_body)
150+
self.assertIn(b'"warehouse"', request_body)
151+
152+
@responses.activate
153+
def test_upload_image_hosted_with_metadata(self):
154+
metadata = {"camera_id": "cam001", "location": "warehouse"}
155+
metadata_encoded = urllib.parse.quote_plus(json.dumps(metadata))
156+
expected_url = (
157+
f"{API_URL}/dataset/{self.PROJECT_URL}/upload?"
158+
f"api_key={self.API_KEY}&name={self.IMAGE_NAME_HOSTED}"
159+
f"&split=train&image={urllib.parse.quote_plus(self.IMAGE_PATH_HOSTED)}"
160+
f"&batch={urllib.parse.quote_plus(DEFAULT_BATCH_NAME)}"
161+
f"&tag=tag1&tag=tag2&metadata={metadata_encoded}"
162+
)
163+
responses.add(responses.POST, expected_url, json={"success": True}, status=200)
164+
165+
result = upload_image(
166+
self.API_KEY,
167+
self.PROJECT_URL,
168+
self.IMAGE_PATH_HOSTED,
169+
hosted_image=True,
170+
tag_names=self.TAG_NAMES_HOSTED,
171+
metadata=metadata,
172+
)
173+
self.assertTrue(result["success"])
174+
124175
def _reset_responses(self):
125176
responses.reset()
126177

0 commit comments

Comments
 (0)