Skip to content

Commit b4fb2f9

Browse files
authored
Video MP4 Upload API (#275)
* initial updates * rerun commit tests * bug fix * Fix check all paths remote * Added export test * Added scene property test * remove frame rate for mp4 * clean up * Docstring update * fix ordering * switch to optional * added erroring tests * fix VideoScene arg ordering * lint * lint again * Changelog fix * Isort lint * Fix docs * remove optional
1 parent 28fd3e6 commit b4fb2f9

File tree

5 files changed

+292
-72
lines changed

5 files changed

+292
-72
lines changed

CHANGELOG.md

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

8-
## [0.10.4](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.10.1)) - 2022-04-22
8+
## [0.10.4](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.10.4)) - 2022-05-02
9+
10+
### Added
11+
912
- Additional check added for KeypointsAnnotation names validation
13+
- MP4 video upload
1014

1115
## [0.10.3](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.10.3) - 2022-04-22
1216

1317
### Fixed
18+
1419
- Polygon and bounding box matching uses Shapely again providing faster evaluations
1520
- Evaluation function passing fixed for Polygon and Boundingbox configurations
1621

1722
## [0.10.1](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.10.1) - 2022-04-21
1823

1924
### Added
25+
2026
- Added check for payload size
2127

2228
## [0.10.0](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.10.0)) - 2022-04-21

nucleus/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@
123123
UPLOAD_TO_SCALE_KEY = "upload_to_scale"
124124
URL_KEY = "url"
125125
VERTICES_KEY = "vertices"
126+
VIDEO_LOCATION_KEY = "video_location"
127+
VIDEO_URL_KEY = "video_url"
126128
VISIBLE_KEY = "visible"
127129
VIDEO_UPLOAD_TYPE_KEY = "video_upload_type"
128130
WIDTH_KEY = "width"

nucleus/scene.py

Lines changed: 108 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
NUM_SENSORS_KEY,
1414
POINTCLOUD_LOCATION_KEY,
1515
REFERENCE_ID_KEY,
16+
VIDEO_LOCATION_KEY,
1617
VIDEO_UPLOAD_TYPE_KEY,
18+
VIDEO_URL_KEY,
1719
)
1820

1921
from .annotation import is_local_path
@@ -419,16 +421,17 @@ class _VideoUploadType(Enum):
419421

420422
@dataclass
421423
class VideoScene(ABC):
422-
"""
423-
Nucleus video datasets are comprised of VideoScenes, which are in turn
424-
comprised of a sequence of :class:`DatasetItems <DatasetItem>` which are
425-
equivalent to frames.
424+
"""Video or sequence of images over time.
425+
426+
Nucleus video datasets are comprised of VideoScenes. These can be
427+
comprised of a single video, or a sequence of :class:`DatasetItems <DatasetItem>`
428+
which are equivalent to frames.
426429
427430
VideoScenes are uploaded to a :class:`Dataset` with any accompanying
428431
metadata. Each of :class:`DatasetItems <DatasetItem>` representing a frame
429432
also accepts metadata.
430433
431-
Note: Uploads with different items will error out (only on scenes that
434+
Note: Updates with different items will error out (only on scenes that
432435
now differ). Existing video are expected to retain the same frames, and only
433436
metadata can be updated. If a video definition is changed (for example,
434437
additional frames added) the update operation will be ignored. If you would
@@ -437,30 +440,31 @@ class VideoScene(ABC):
437440
438441
Parameters:
439442
reference_id (str): User-specified identifier to reference the scene.
440-
frame_rate (int): Frame rate of the video.
441443
attachment_type (str): The type of attachments being uploaded as a string literal.
442-
Currently, videos can only be uploaded as an array of frames, so the only
443-
accepted attachment_type is "image".
444-
items (Optional[List[:class:`DatasetItem`]]): List of items representing frames,
445-
to be a part of the scene. A scene can be created before items have been added
446-
to it, but must be non-empty when uploading to a :class:`Dataset`. A video scene
447-
can contain a maximum of 3000 items.
444+
If the video is uploaded as an array of frames, the attachment_type is "image".
445+
If the video is uploaded as an MP4, the attachment_type is "video".
446+
frame_rate (Optional[int]): Required if attachment_type is "image". Frame rate of the video.
447+
video_location (Optional[str]): Required if attachment_type is "video". The remote URL
448+
containing the video MP4. Remote formats supported include any URL (``http://``
449+
or ``https://``) or URIs for AWS S3, Azure, or GCS (i.e. ``s3://``, ``gcs://``).
450+
items (Optional[List[:class:`DatasetItem`]]): Required if attachment_type is "image".
451+
List of items representing frames, to be a part of the scene. A scene can be created
452+
before items have been added to it, but must be non-empty when uploading to
453+
a :class:`Dataset`. A video scene can contain a maximum of 3000 items.
448454
metadata (Optional[Dict]): Optional metadata to include with the scene.
449455
450456
Refer to our `guide to uploading video data
451457
<https://nucleus.scale.com/docs/uploading-video-data>`_ for more info!
452458
"""
453459

454460
reference_id: str
455-
frame_rate: int
456461
attachment_type: _VideoUploadType
462+
frame_rate: Optional[int] = None
463+
video_location: Optional[str] = None
457464
items: List[DatasetItem] = field(default_factory=list)
458465
metadata: Optional[dict] = field(default_factory=dict)
459466

460467
def __post_init__(self):
461-
assert (
462-
self.attachment_type != _VideoUploadType.IMAGE
463-
), "Videos can currently only be uploaded from frames"
464468
if self.metadata is None:
465469
self.metadata = {}
466470

@@ -469,41 +473,67 @@ def __eq__(self, other):
469473
[
470474
self.reference_id == other.reference_id,
471475
self.items == other.items,
476+
self.video_location == other.video_location,
472477
self.metadata == other.metadata,
473478
]
474479
)
475480

476481
@property
477482
def length(self) -> int:
478-
"""Number of items in the scene."""
483+
"""Gets number of items in the scene for videos uploaded as an array of images."""
484+
assert (
485+
self.video_location is None
486+
), "Videos uploaded as an mp4 have no length"
479487
return len(self.items)
480488

481489
def validate(self):
482490
# TODO: make private
483-
assert self.frame_rate > 0, "Frame rate must be at least 1"
484-
assert self.length > 0, "Must have at least 1 item in a scene"
485-
for item in self.items:
486-
assert isinstance(
487-
item, DatasetItem
488-
), "Each item in a scene must be a DatasetItem object"
491+
assert self.attachment_type in ("image", "video")
492+
if self.attachment_type == "image":
493+
assert (
494+
self.frame_rate > 0
495+
), "When attachment_type='image' frame rate must be at least 1"
496+
assert (
497+
self.items and self.length > 0
498+
), "When attachment_type='image' scene must have a list of items of length at least 1"
489499
assert (
490-
item.image_location is not None
491-
), "Each item in a video scene must have an image_location"
500+
not self.video_location
501+
), "No video location is accepted when attachment_type='image'"
502+
for item in self.items:
503+
assert isinstance(
504+
item, DatasetItem
505+
), "Each item in a scene must be a DatasetItem object"
506+
assert (
507+
item.image_location is not None
508+
), "Each item in a video scene must have an image_location"
509+
assert (
510+
item.upload_to_scale is not False
511+
), "Skipping upload to Scale is not currently implemented for videos"
512+
if self.attachment_type == "video":
492513
assert (
493-
item.upload_to_scale is not False
494-
), "Skipping upload to Scale is not currently implemented for videos"
514+
self.video_location
515+
), "When attachment_type='video' a video_location is required"
516+
assert (
517+
not self.frame_rate
518+
), "No frame rate is accepted when attachment_type='video'"
519+
assert (
520+
not self.items
521+
), "No list of items is accepted when attachment_type='video'"
495522

496523
def add_item(
497524
self, item: DatasetItem, index: int = None, update: bool = False
498525
) -> None:
499-
"""Adds DatasetItem to the specified index.
526+
"""Adds DatasetItem to the specified index for videos uploaded as an array of images.
500527
501528
Parameters:
502529
item (:class:`DatasetItem`): Video item to add.
503530
index: Serial index at which to add the item.
504531
update: Whether to overwrite the item at the specified index, if it
505532
exists. Default is False.
506533
"""
534+
assert (
535+
self.video_location is None
536+
), "Cannot add item to a video uploaded as an mp4"
507537
if index is None:
508538
index = len(self.items)
509539
assert (
@@ -515,44 +545,57 @@ def add_item(
515545
self.items.append(item)
516546

517547
def get_item(self, index: int) -> DatasetItem:
518-
"""Fetches the DatasetItem at the specified index.
548+
"""Fetches the DatasetItem at the specified index for videos uploaded as an array of images.
519549
520550
Parameters:
521551
index: Serial index for which to retrieve the DatasetItem.
522552
523553
Return:
524554
:class:`DatasetItem`: DatasetItem at the specified index."""
555+
assert (
556+
self.video_location is None
557+
), "Cannot get item from a video uploaded as an mp4"
525558
if index < 0 or index > len(self.items):
526559
raise ValueError(
527560
f"This scene does not have an item at index {index}"
528561
)
529562
return self.items[index]
530563

531564
def get_items(self) -> List[DatasetItem]:
532-
"""Fetches a sorted list of DatasetItems of the scene.
565+
"""Fetches a sorted list of DatasetItems of the scene for videos uploaded as an array of images.
533566
534567
Returns:
535568
List[:class:`DatasetItem`]: List of DatasetItems, sorted by index ascending.
536569
"""
570+
assert (
571+
self.video_location is None
572+
), "Cannot get items from a video uploaded as an mp4"
537573
return self.items
538574

539575
def info(self):
540-
"""Fetches information about the scene.
576+
"""Fetches information about the video scene.
541577
542578
Returns:
543579
Payload containing::
544580
545581
{
546582
"reference_id": str,
547-
"length": int,
548-
"num_sensors": int
583+
"length": Optional[int],
584+
"frame_rate": int,
585+
"video_url": Optional[str],
549586
}
550587
"""
551-
return {
588+
payload: Dict[str, Any] = {
552589
REFERENCE_ID_KEY: self.reference_id,
553-
FRAME_RATE_KEY: self.frame_rate,
554-
LENGTH_KEY: self.length,
555590
}
591+
if self.frame_rate:
592+
payload[FRAME_RATE_KEY] = self.frame_rate
593+
if self.video_location:
594+
payload[VIDEO_URL_KEY] = self.video_location
595+
if self.items:
596+
payload[LENGTH_KEY] = self.length
597+
598+
return payload
556599

557600
@classmethod
558601
def from_json(cls, payload: dict):
@@ -561,24 +604,31 @@ def from_json(cls, payload: dict):
561604
items = [DatasetItem.from_json(item) for item in items_payload]
562605
return cls(
563606
reference_id=payload[REFERENCE_ID_KEY],
564-
frame_rate=payload[FRAME_RATE_KEY],
607+
frame_rate=payload.get(FRAME_RATE_KEY, None),
565608
attachment_type=payload[VIDEO_UPLOAD_TYPE_KEY],
566609
items=items,
567610
metadata=payload.get(METADATA_KEY, {}),
611+
video_location=payload.get(VIDEO_URL_KEY, None),
568612
)
569613

570614
def to_payload(self) -> dict:
571615
"""Serializes scene object to schematized JSON dict."""
572616
self.validate()
573-
items_payload = [item.to_payload(is_scene=True) for item in self.items]
574617
payload: Dict[str, Any] = {
575618
REFERENCE_ID_KEY: self.reference_id,
576619
VIDEO_UPLOAD_TYPE_KEY: self.attachment_type,
577-
FRAME_RATE_KEY: self.frame_rate,
578-
FRAMES_KEY: items_payload,
579620
}
621+
if self.frame_rate:
622+
payload[FRAME_RATE_KEY] = self.frame_rate
580623
if self.metadata:
581624
payload[METADATA_KEY] = self.metadata
625+
if self.video_location:
626+
payload[VIDEO_URL_KEY] = self.video_location
627+
if self.items:
628+
items_payload = [
629+
item.to_payload(is_scene=True) for item in self.items
630+
]
631+
payload[FRAMES_KEY] = items_payload
582632
return payload
583633

584634
def to_json(self) -> str:
@@ -590,16 +640,24 @@ def check_all_scene_paths_remote(
590640
scenes: Union[List[LidarScene], List[VideoScene]]
591641
):
592642
for scene in scenes:
593-
for item in scene.get_items():
594-
pointcloud_location = getattr(item, POINTCLOUD_LOCATION_KEY)
595-
if pointcloud_location and is_local_path(pointcloud_location):
596-
raise ValueError(
597-
f"All paths for DatasetItems in a Scene must be remote, but {item.pointcloud_location} is either "
598-
"local, or a remote URL type that is not supported."
599-
)
600-
image_location = getattr(item, IMAGE_LOCATION_KEY)
601-
if image_location and is_local_path(image_location):
643+
if isinstance(scene, VideoScene) and scene.video_location:
644+
video_location = getattr(scene, VIDEO_LOCATION_KEY)
645+
if video_location and is_local_path(video_location):
602646
raise ValueError(
603-
f"All paths for DatasetItems in a Scene must be remote, but {item.image_location} is either "
647+
f"All paths for videos must be remote, but {scene.video_location} is either "
604648
"local, or a remote URL type that is not supported."
605649
)
650+
else:
651+
for item in scene.get_items():
652+
pointcloud_location = getattr(item, POINTCLOUD_LOCATION_KEY)
653+
if pointcloud_location and is_local_path(pointcloud_location):
654+
raise ValueError(
655+
f"All paths for DatasetItems in a Scene must be remote, but {item.pointcloud_location} is either "
656+
"local, or a remote URL type that is not supported."
657+
)
658+
image_location = getattr(item, IMAGE_LOCATION_KEY)
659+
if image_location and is_local_path(image_location):
660+
raise ValueError(
661+
f"All paths for DatasetItems in a Scene must be remote, but {item.image_location} is either "
662+
"local, or a remote URL type that is not supported."
663+
)

0 commit comments

Comments
 (0)