Skip to content

Commit eac724d

Browse files
authored
Video Upload API (#221)
* Initial async functionality * Made index an optional arg, switched to items instead of frames * Update some docs * Added tests * Added some docs * Better doc strings * docs * typo fix * more typos * Updates to pyproject.toml and CHANGELOG.md
1 parent b237e6e commit eac724d

File tree

11 files changed

+600
-52
lines changed

11 files changed

+600
-52
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ All notable changes to the [Nucleus Python Client](https://github.com/scaleapi/n
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [0.6.6](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.6.6) - 2021-02-18
8+
9+
### Added
10+
- Video upload support
11+
712
## [0.6.5](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.6.5) - 2021-02-16
813

914
### Fixed

nucleus/__init__.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,8 @@
1414
"DatasetItem",
1515
"DatasetItemRetrievalError",
1616
"Frame",
17-
"Frame",
18-
"LidarScene",
1917
"LidarScene",
18+
"VideoScene",
2019
"Model",
2120
"ModelCreationError",
2221
# "MultiCategoryAnnotation", # coming soon!
@@ -124,7 +123,7 @@
124123
SegmentationPrediction,
125124
)
126125
from .retry_strategy import RetryStrategy
127-
from .scene import Frame, LidarScene
126+
from .scene import Frame, LidarScene, VideoScene
128127
from .slice import Slice
129128
from .upload_response import UploadResponse
130129
from .validate import Validate

nucleus/constants.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
ERROR_CODES = "error_codes"
4545
ERROR_ITEMS = "upload_errors"
4646
ERROR_PAYLOAD = "error_payload"
47+
FRAME_RATE_KEY = "frame_rate"
4748
FRAMES_KEY = "frames"
4849
FX_KEY = "fx"
4950
FY_KEY = "fy"
@@ -101,6 +102,10 @@
101102
UPLOAD_TO_SCALE_KEY = "upload_to_scale"
102103
URL_KEY = "url"
103104
VERTICES_KEY = "vertices"
105+
VIDEO_FRAME_LOCATION_KEY = "video_frame_location"
106+
VIDEO_FRAME_URL_KEY = "video_frame_url"
107+
VIDEO_KEY = "video"
108+
VIDEO_UPLOAD_TYPE_KEY = "video_upload_type"
104109
WIDTH_KEY = "width"
105110
YAW_KEY = "yaw"
106111
W_KEY = "w"

nucleus/dataset.py

Lines changed: 75 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
REQUEST_ID_KEY,
4848
SLICE_ID_KEY,
4949
UPDATE_KEY,
50+
VIDEO_UPLOAD_TYPE_KEY,
5051
)
5152
from .data_transfer_object.dataset_info import DatasetInfo
5253
from .data_transfer_object.dataset_size import DatasetSize
@@ -65,7 +66,7 @@
6566
construct_model_run_creation_payload,
6667
construct_taxonomy_payload,
6768
)
68-
from .scene import LidarScene, Scene, check_all_scene_paths_remote
69+
from .scene import LidarScene, Scene, VideoScene, check_all_scene_paths_remote
6970
from .slice import Slice
7071
from .upload_response import UploadResponse
7172

@@ -405,16 +406,17 @@ def ingest_tasks(self, task_ids: List[str]) -> dict:
405406

406407
def append(
407408
self,
408-
items: Union[Sequence[DatasetItem], Sequence[LidarScene]],
409+
items: Union[
410+
Sequence[DatasetItem], Sequence[LidarScene], Sequence[VideoScene]
411+
],
409412
update: bool = False,
410413
batch_size: int = 20,
411414
asynchronous: bool = False,
412415
) -> Union[Dict[Any, Any], AsyncJob, UploadResponse]:
413416
"""Appends items or scenes to a dataset.
414417
415418
.. note::
416-
Datasets can only accept one of :class:`DatasetItems <DatasetItem>`
417-
or :class:`Scenes <LidarScene>`, never both.
419+
Datasets can only accept one of DatasetItems or Scenes, never both.
418420
419421
This behavior is set during Dataset :meth:`creation
420422
<NucleusClient.create_dataset>` with the ``is_scene`` flag.
@@ -478,13 +480,14 @@ def append(
478480
Union[ \
479481
Sequence[:class:`DatasetItem`], \
480482
Sequence[:class:`LidarScene`] \
483+
Sequence[:class:`VideoScene`]
481484
]): List of items or scenes to upload.
482485
batch_size: Size of the batch for larger uploads. Default is 20.
483486
update: Whether or not to overwrite metadata on reference ID collision.
484487
Default is False.
485488
asynchronous: Whether or not to process the upload asynchronously (and
486-
return an :class:`AsyncJob` object). This is highly encouraged for
487-
3D data to drastically increase throughput. Default is False.
489+
return an :class:`AsyncJob` object). This is required when uploading
490+
scenes. Default is False.
488491
489492
Returns:
490493
For scenes
@@ -508,17 +511,26 @@ def append(
508511
dataset_items = [
509512
item for item in items if isinstance(item, DatasetItem)
510513
]
511-
scenes = [item for item in items if isinstance(item, LidarScene)]
512-
if dataset_items and scenes:
514+
lidar_scenes = [item for item in items if isinstance(item, LidarScene)]
515+
video_scenes = [item for item in items if isinstance(item, VideoScene)]
516+
if dataset_items and (lidar_scenes or video_scenes):
513517
raise Exception(
514518
"You must append either DatasetItems or Scenes to the dataset."
515519
)
516-
if scenes:
520+
if lidar_scenes:
517521
assert (
518522
asynchronous
519-
), "In order to avoid timeouts, you must set asynchronous=True when uploading scenes."
523+
), "In order to avoid timeouts, you must set asynchronous=True when uploading 3D scenes."
520524

521-
return self._append_scenes(scenes, update, asynchronous)
525+
return self._append_scenes(lidar_scenes, update, asynchronous)
526+
if video_scenes:
527+
assert (
528+
asynchronous
529+
), "In order to avoid timeouts, you must set asynchronous=True when uploading videos."
530+
531+
return self._append_video_scenes(
532+
video_scenes, update, asynchronous
533+
)
522534

523535
check_for_duplicate_reference_ids(dataset_items)
524536

@@ -601,6 +613,51 @@ def _append_scenes(
601613
)
602614
return response
603615

616+
def _append_video_scenes(
617+
self,
618+
scenes: List[VideoScene],
619+
update: Optional[bool] = False,
620+
asynchronous: Optional[bool] = False,
621+
) -> Union[dict, AsyncJob]:
622+
# TODO: make private in favor of Dataset.append invocation
623+
if not self.is_scene:
624+
raise Exception(
625+
"Your dataset is not a scene dataset but only supports single dataset items. "
626+
"In order to be able to add scenes, please create another dataset with "
627+
"client.create_dataset(<dataset_name>, is_scene=True) or add the scenes to "
628+
"an existing scene dataset."
629+
)
630+
631+
for scene in scenes:
632+
scene.validate()
633+
634+
if not asynchronous:
635+
print(
636+
"WARNING: Processing videos usually takes several seconds. As a result, synchronous video scene upload"
637+
"requests are likely to timeout. For large uploads, we recommend using the flag asynchronous=True "
638+
"to avoid HTTP timeouts. Please see"
639+
"https://dashboard.scale.com/nucleus/docs/api?language=python#guide-for-large-ingestions"
640+
" for details."
641+
)
642+
643+
if asynchronous:
644+
# TODO check_all_scene_paths_remote(scenes)
645+
request_id = serialize_and_write_to_presigned_url(
646+
scenes, self.id, self._client
647+
)
648+
response = self._client.make_request(
649+
payload={REQUEST_ID_KEY: request_id, UPDATE_KEY: update},
650+
route=f"{self.id}/upload_video_scenes?async=1",
651+
)
652+
return AsyncJob.from_json(response, self._client)
653+
654+
payload = construct_append_scenes_payload(scenes, update)
655+
response = self._client.make_request(
656+
payload=payload,
657+
route=f"{self.id}/upload_video_scenes",
658+
)
659+
return response
660+
604661
def iloc(self, i: int) -> dict:
605662
"""Retrieves dataset item by absolute numerical index.
606663
@@ -1082,13 +1139,14 @@ def get_scene(self, reference_id: str) -> Scene:
10821139
:class:`Scene<LidarScene>`: A scene object containing frames, which
10831140
in turn contain pointcloud or image items.
10841141
"""
1085-
return LidarScene.from_json(
1086-
self._client.make_request(
1087-
payload=None,
1088-
route=f"dataset/{self.id}/scene/{reference_id}",
1089-
requests_command=requests.get,
1090-
)
1142+
response = self._client.make_request(
1143+
payload=None,
1144+
route=f"dataset/{self.id}/scene/{reference_id}",
1145+
requests_command=requests.get,
10911146
)
1147+
if VIDEO_UPLOAD_TYPE_KEY in response:
1148+
return VideoScene.from_json(response)
1149+
return LidarScene.from_json(response)
10921150

10931151
def export_predictions(self, model):
10941152
"""Fetches all predictions of a model that were uploaded to the dataset.

nucleus/dataset_item.py

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
TYPE_KEY,
2323
UPLOAD_TO_SCALE_KEY,
2424
URL_KEY,
25+
VIDEO_FRAME_URL_KEY,
2526
W_KEY,
2627
X_KEY,
2728
Y_KEY,
@@ -120,34 +121,42 @@ def to_payload(self) -> dict:
120121
class DatasetItemType(Enum):
121122
IMAGE = "image"
122123
POINTCLOUD = "pointcloud"
124+
VIDEO = "video"
123125

124126

125127
@dataclass # pylint: disable=R0902
126128
class DatasetItem: # pylint: disable=R0902
127-
"""A dataset item is an image or pointcloud that has associated metadata.
129+
"""A dataset item is an image, pointcloud or video frame that has associated metadata.
128130
129131
Note: for 3D data, please include a :class:`CameraParams` object under a key named
130132
"camera_params" within the metadata dictionary. This will allow for projecting
131133
3D annotations to any image within a scene.
132134
133135
Args:
134-
image_location (Optional[str]): Required if pointcloud_location not present: The
135-
location containing the image for the given row of data. This can be a
136-
local path, or a remote URL. Remote formats supported include any URL
137-
(``http://`` or ``https://``) or URIs for AWS S3, Azure, or GCS
138-
(i.e. ``s3://``, ``gcs://``).
139-
140-
pointcloud_location (Optional[str]): Required if image_location not
141-
present: The remote URL containing the pointcloud JSON. Remote
142-
formats supported include any URL (``http://`` or ``https://``) or
143-
URIs for AWS S3, Azure, or GCS (i.e. ``s3://``, ``gcs://``).
136+
image_location (Optional[str]): Required if pointcloud_location and
137+
video_frame_location are not present: The location containing the image for
138+
the given row of data. This can be a local path, or a remote URL. Remote
139+
formats supported include any URL (``http://`` or ``https://``) or URIs for
140+
AWS S3, Azure, or GCS (i.e. ``s3://``, ``gcs://``).
141+
142+
pointcloud_location (Optional[str]): Required if image_location and
143+
video_frame_location are not present: The remote URL containing the
144+
pointcloud JSON. Remote formats supported include any URL (``http://``
145+
or ``https://``) or URIs for AWS S3, Azure, or GCS (i.e. ``s3://``,
146+
``gcs://``).
147+
148+
video_frame_location (Optional[str]): Required if image_location and
149+
pointcloud_location are not present: The remote URL containing the
150+
video frame image. Remote formats supported include any URL (``http://``
151+
or ``https://``) or URIs for AWS S3, Azure, or GCS (i.e. ``s3://``,
152+
``gcs://``).
144153
145154
reference_id (Optional[str]): A user-specified identifier to reference the
146155
item.
147156
148157
metadata (Optional[dict]): Extra information about the particular
149158
dataset item. ints, floats, string values will be made searchable in
150-
the query bar by the key in this dict For example, ``{"animal":
159+
the query bar by the key in this dict. For example, ``{"animal":
151160
"dog"}`` will become searchable via ``metadata.animal = "dog"``.
152161
153162
Categorical data can be passed as a string and will be treated
@@ -190,9 +199,10 @@ class DatasetItem: # pylint: disable=R0902
190199
upload_to_scale (Optional[bool]): Set this to false in order to use
191200
`privacy mode <https://nucleus.scale.com/docs/privacy-mode>`_.
192201
193-
Setting this to false means the actual data within the item (i.e. the
194-
image or pointcloud) will not be uploaded to scale meaning that you can
195-
send in links that are only accessible to certain users, and not to Scale.
202+
Setting this to false means the actual data within the item will not be
203+
uploaded to scale meaning that you can send in links that are only accessible
204+
to certain users, and not to Scale. Skipping upload to Scale is currently only
205+
implemented for images.
196206
"""
197207

198208
image_location: Optional[str] = None
@@ -202,23 +212,33 @@ class DatasetItem: # pylint: disable=R0902
202212
metadata: Optional[dict] = None
203213
pointcloud_location: Optional[str] = None
204214
upload_to_scale: Optional[bool] = True
215+
video_frame_location: Optional[str] = None
205216

206217
def __post_init__(self):
207218
assert self.reference_id != "DUMMY_VALUE", "reference_id is required."
208-
assert bool(self.image_location) != bool(
209-
self.pointcloud_location
210-
), "Must specify exactly one of the image_location, pointcloud_location parameters"
211-
if self.pointcloud_location and not self.upload_to_scale:
219+
assert (
220+
bool(self.image_location)
221+
+ bool(self.pointcloud_location)
222+
+ bool(self.video_frame_location)
223+
== 1
224+
), "Must specify exactly one of the image_location, pointcloud_location, video_frame_location parameters"
225+
if (
226+
self.pointcloud_location or self.video_frame_location
227+
) and not self.upload_to_scale:
212228
raise NotImplementedError(
213-
"Skipping upload to Scale is not currently implemented for pointclouds."
229+
"Skipping upload to Scale is not currently implemented for pointclouds and videos."
214230
)
215231
self.local = (
216232
is_local_path(self.image_location) if self.image_location else None
217233
)
218234
self.type = (
219235
DatasetItemType.IMAGE
220236
if self.image_location
221-
else DatasetItemType.POINTCLOUD
237+
else (
238+
DatasetItemType.POINTCLOUD
239+
if self.pointcloud_location
240+
else DatasetItemType.VIDEO
241+
)
222242
)
223243
camera_params = (
224244
self.metadata.get(CAMERA_PARAMS_KEY, None)
@@ -238,6 +258,7 @@ def from_json(cls, payload: dict):
238258
return cls(
239259
image_location=image_url,
240260
pointcloud_location=payload.get(POINTCLOUD_URL_KEY, None),
261+
video_frame_location=payload.get(VIDEO_FRAME_URL_KEY, None),
241262
reference_id=payload.get(REFERENCE_ID_KEY, None),
242263
metadata=payload.get(METADATA_KEY, {}),
243264
upload_to_scale=payload.get(UPLOAD_TO_SCALE_KEY, True),
@@ -260,13 +281,15 @@ def to_payload(self, is_scene=False) -> dict:
260281
payload[URL_KEY] = self.image_location
261282
elif self.pointcloud_location:
262283
payload[URL_KEY] = self.pointcloud_location
284+
elif self.video_frame_location:
285+
payload[URL_KEY] = self.video_frame_location
263286
payload[TYPE_KEY] = self.type.value
264287
if self.camera_params:
265288
payload[CAMERA_PARAMS_KEY] = self.camera_params.to_payload()
266289
else:
267290
assert (
268291
self.image_location
269-
), "Must specify image_location for DatasetItems not in a LidarScene"
292+
), "Must specify image_location for DatasetItems not in a LidarScene or VideoScene"
270293
payload[IMAGE_URL_KEY] = self.image_location
271294
payload[UPLOAD_TO_SCALE_KEY] = self.upload_to_scale
272295

nucleus/payload_constructor.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
PolygonPrediction,
3333
SegmentationPrediction,
3434
)
35-
from .scene import LidarScene
35+
from .scene import LidarScene, VideoScene
3636

3737

3838
def construct_append_payload(
@@ -50,7 +50,8 @@ def construct_append_payload(
5050

5151

5252
def construct_append_scenes_payload(
53-
scene_list: List[LidarScene], update: Optional[bool] = False
53+
scene_list: Union[List[LidarScene], List[VideoScene]],
54+
update: Optional[bool] = False,
5455
) -> dict:
5556
scenes = []
5657
for scene in scene_list:

0 commit comments

Comments
 (0)