From 4f848c92e1cff6875f5eae72f88573f920720507 Mon Sep 17 00:00:00 2001 From: Bonhun Koo Date: Thu, 17 Aug 2023 15:19:40 +0900 Subject: [PATCH] support video annotation (#1124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Summary [CVS-116105](https://jira.devtools.intel.com/browse/CVS-116105) Support the video annotation type for 'datumaro', 'datumaro_binary' format ### How to test ### Checklist - [x] I have added unit tests to cover my changes.​ - [x] I have added integration tests to cover my changes.​ - [x] I have added the description of my changes into [CHANGELOG](https://github.com/openvinotoolkit/datumaro/blob/develop/CHANGELOG.md).​ - [x] I have updated the [documentation](https://github.com/openvinotoolkit/datumaro/tree/develop/docs) accordingly ### License - [x] I submit _my code changes_ under the same [MIT License](https://github.com/openvinotoolkit/datumaro/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. - [x] I have updated the license header for each file (see an example below). ```python # Copyright (C) 2023 Intel Corporation # # SPDX-License-Identifier: MIT ``` --- CHANGELOG.md | 2 + .../docs/data-formats/datumaro_format.md | 2 +- .../docs/data-formats/formats/datumaro.md | 2 + .../data-formats/formats/datumaro_binary.md | 3 ++ src/datumaro/components/annotation.py | 6 +++ src/datumaro/components/exporter.py | 32 +++++++++++- src/datumaro/components/media.py | 18 +++++++ .../plugins/data_formats/arrow/exporter.py | 1 + .../plugins/data_formats/datumaro/base.py | 45 +++++++++++++++-- .../plugins/data_formats/datumaro/exporter.py | 28 ++++++++++- .../plugins/data_formats/datumaro/format.py | 1 + .../data_formats/datumaro_binary/base.py | 5 +- .../data_formats/datumaro_binary/exporter.py | 5 +- .../data_formats/datumaro_binary/format.py | 1 + .../datumaro_binary/mapper/annotation.py | 8 +-- .../datumaro_binary/mapper/media.py | 34 ++++++++++++- tests/integration/cli/test_video.py | 50 +++++++++++++++++++ tests/unit/test_video.py | 48 +++++++++++++++++- tests/utils/test_utils.py | 5 +- 19 files changed, 280 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eb9797b15..0368b1bbad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### New features - Add tabular data import/export () +- Support video annotation import/export + () ### Enhancements - Remove xfail marks from the convert integration tests diff --git a/docs/source/docs/data-formats/datumaro_format.md b/docs/source/docs/data-formats/datumaro_format.md index d16282eeab..37401715a9 100644 --- a/docs/source/docs/data-formats/datumaro_format.md +++ b/docs/source/docs/data-formats/datumaro_format.md @@ -129,7 +129,7 @@ dataset/ │ | ├── img002.png │ | └── ... │ └── ... -│ +├── videos/ # directory to store video files └── annotations/ ├── train.json # annotation file with training data ├── val.json # annotation file with validation data diff --git a/docs/source/docs/data-formats/formats/datumaro.md b/docs/source/docs/data-formats/formats/datumaro.md index 0f5ede706c..70679408a3 100644 --- a/docs/source/docs/data-formats/formats/datumaro.md +++ b/docs/source/docs/data-formats/formats/datumaro.md @@ -11,6 +11,7 @@ Supported media types: - `Image` - `PointCloud` +- `VideoFrame` Supported annotation types: @@ -56,6 +57,7 @@ A Datumaro dataset directory should have the following structure: │ ├── │ ├── │ └── ... + ├── videos/ # directory to store video files └── annotations/ ├── .json ├── .json diff --git a/docs/source/docs/data-formats/formats/datumaro_binary.md b/docs/source/docs/data-formats/formats/datumaro_binary.md index 5ab6a62ddc..6454eff146 100644 --- a/docs/source/docs/data-formats/formats/datumaro_binary.md +++ b/docs/source/docs/data-formats/formats/datumaro_binary.md @@ -37,6 +37,7 @@ Dataset/ │ └── val/ │ ├── │ └── ... +├── videos/ # directory to store video files └── annotations/ ├── instances_train2017.json └── instances_val2017.json @@ -58,6 +59,7 @@ Supported media types: - `Image` - `PointCloud` +- `VideoFrame` Supported annotation types: @@ -103,6 +105,7 @@ A DatumaroBinary dataset directory should have the following structure: │ ├── │ ├── │ └── ... + ├── videos/ └── annotations/ ├── .datum ├── .datum diff --git a/src/datumaro/components/annotation.py b/src/datumaro/components/annotation.py index 5bf13e5b1d..fe883ecafb 100644 --- a/src/datumaro/components/annotation.py +++ b/src/datumaro/components/annotation.py @@ -53,6 +53,7 @@ class AnnotationType(IntEnum): COORDINATE_ROUNDING_DIGITS = 2 CHECK_POLYGON_EQ_EPSILONE = 1e-7 NO_GROUP = 0 +NO_OBJECT_ID = -1 @attrs(slots=True, kw_only=True, order=False) @@ -83,6 +84,11 @@ class Annotation: # single object. The value of 0 means there is no group. group: int = field(default=NO_GROUP, validator=default_if_none(int)) + # obeject identifier over the multiple items + # e.g.) in a video, person 'A' could be annotated on the multiple frame images + # the user could assign >=0 value as id of person 'A'. + object_id: int = field(default=NO_OBJECT_ID, validator=default_if_none(int)) + _type = AnnotationType.unknown @property diff --git a/src/datumaro/components/exporter.py b/src/datumaro/components/exporter.py index c63efb0c9b..62fa659f8b 100644 --- a/src/datumaro/components/exporter.py +++ b/src/datumaro/components/exporter.py @@ -23,7 +23,7 @@ DatumaroError, ItemExportError, ) -from datumaro.components.media import Image, PointCloud +from datumaro.components.media import Image, PointCloud, VideoFrame from datumaro.components.progress_reporting import NullProgressReporter, ProgressReporter from datumaro.util.meta_file_util import save_hashkey_file, save_meta_file from datumaro.util.os_util import rmtree @@ -323,6 +323,7 @@ def __init__( save_media: bool, images_dir: str, pcd_dir: str, + video_dir: str, crypter: Crypter = NULL_CRYPTER, image_ext: Optional[str] = None, default_image_ext: Optional[str] = None, @@ -332,6 +333,7 @@ def __init__( self._save_media = save_media self._images_dir = images_dir self._pcd_dir = pcd_dir + self._video_dir = video_dir self._crypter = crypter self._image_ext = image_ext self._default_image_ext = default_image_ext @@ -363,6 +365,14 @@ def make_pcd_extra_image_filename(self, item, idx, image, *, name=None, subdir=N item, name=name if name else f"{item.id}/extra_image_{idx}", subdir=subdir ) + self.find_image_ext(image) + def make_video_filename(self, item, *, name=None): + if isinstance(item, DatasetItem) and isinstance(item.media, VideoFrame): + video_file_name = osp.basename(item.media.video.path) + else: + assert "Video item type should be VideoFrame" + + return video_file_name + def save_image( self, item: DatasetItem, @@ -412,6 +422,26 @@ def helper(i, image): item.media.save(path, helper, crypter=NULL_CRYPTER) + def save_video( + self, + item: DatasetItem, + *, + basedir: Optional[str] = None, + fname: Optional[str] = None, + ): + if not item.media or not isinstance(item.media, VideoFrame): + log.warning("Item '%s' has no video", item.id) + return + basedir = self._video_dir if basedir is None else basedir + fname = self.make_video_filename(item) if fname is None else fname + + path = osp.join(basedir, fname) + path = osp.abspath(path) + + os.makedirs(osp.dirname(path), exist_ok=True) + + item.media.video.save(path, crypter=NULL_CRYPTER) + @property def images_dir(self) -> str: return self._images_dir diff --git a/src/datumaro/components/media.py b/src/datumaro/components/media.py index 0625f763e6..94d0a0b0eb 100644 --- a/src/datumaro/components/media.py +++ b/src/datumaro/components/media.py @@ -559,6 +559,10 @@ def index(self) -> int: def video(self) -> Video: return self._video + @property + def path(self) -> str: + return self._video.path + class _VideoFrameIterator(Iterator[VideoFrame]): """ @@ -808,6 +812,20 @@ def __hash__(self): # Required for caching return hash((self._path, self._step, self._start_frame, self._end_frame)) + def save( + self, + fp: Union[str, io.IOBase], + crypter: Crypter = NULL_CRYPTER, + ): + if isinstance(fp, str): + os.makedirs(osp.dirname(fp), exist_ok=True) + if isinstance(fp, str): + if fp != self.path: + shutil.copyfile(self.path, fp) + elif isinstance(fp, io.IOBase): + with open(self.path, "rb") as f_video: + fp.write(f_video.read()) + @property def path(self) -> str: """Path to the media file""" diff --git a/src/datumaro/plugins/data_formats/arrow/exporter.py b/src/datumaro/plugins/data_formats/arrow/exporter.py index 42723591b0..ce9a47b8e3 100644 --- a/src/datumaro/plugins/data_formats/arrow/exporter.py +++ b/src/datumaro/plugins/data_formats/arrow/exporter.py @@ -288,6 +288,7 @@ def create_writer(self, subset: str, ctx: ExportContext) -> _SubsetWriter: save_media=self._save_media, images_dir="", pcd_dir="", + video_dir="", crypter=NULL_CRYPTER, image_ext=self._image_ext, default_image_ext=self._default_image_ext, diff --git a/src/datumaro/plugins/data_formats/datumaro/base.py b/src/datumaro/plugins/data_formats/datumaro/base.py index b59ebcca9e..6f923fd6bd 100644 --- a/src/datumaro/plugins/data_formats/datumaro/base.py +++ b/src/datumaro/plugins/data_formats/datumaro/base.py @@ -10,6 +10,7 @@ from json_stream.base import StreamingJSONObject from datumaro.components.annotation import ( + NO_OBJECT_ID, AnnotationType, Bbox, Caption, @@ -28,7 +29,7 @@ from datumaro.components.dataset_base import DatasetItem, SubsetBase from datumaro.components.errors import DatasetImportError, MediaTypeError from datumaro.components.importer import ImportContext -from datumaro.components.media import Image, MediaElement, MediaType, PointCloud +from datumaro.components.media import Image, MediaElement, MediaType, PointCloud, Video, VideoFrame from datumaro.util import parse_json_file, to_dict_from_streaming_json from datumaro.version import __version__ @@ -45,12 +46,15 @@ def __init__( rootpath: str, images_dir: str, pcd_dir: str, + video_dir: str, ctx: ImportContext, ) -> None: self._subset = subset self._rootpath = rootpath self._images_dir = images_dir self._pcd_dir = pcd_dir + self._video_dir = video_dir + self._videos = {} self._ctx = ctx self._reader = self._init_reader(path) @@ -174,6 +178,19 @@ def _parse_item(self, item_desc: Dict) -> Optional[DatasetItem]: if self.media_type == MediaElement: self.media_type = PointCloud + video_frame_info = item_desc.get("video_frame") + if media and video_frame_info: + raise MediaTypeError("Dataset cannot contain multiple media types") + if video_frame_info: + video_path = osp.join(self._video_dir, video_frame_info.get("video_path")) + if video_path not in self._videos: + self._videos[video_path] = Video(video_path) + video = self._videos[video_path] + + frame_index = video_frame_info.get("frame_index") + + media = VideoFrame(video, frame_index) + media_desc = item_desc.get("media") if not media and media_desc and media_desc.get("path"): media = MediaElement(path=media_desc.get("path")) @@ -203,6 +220,7 @@ def _load_annotations(self, item: Dict): ann_type = AnnotationType[ann["type"]] attributes = ann.get("attributes") group = ann.get("group") + object_id = ann.get("object_id", NO_OBJECT_ID) label_id = ann.get("label_id") z_order = ann.get("z_order") @@ -210,7 +228,13 @@ def _load_annotations(self, item: Dict): if ann_type == AnnotationType.label: loaded.append( - Label(label=label_id, id=ann_id, attributes=attributes, group=group) + Label( + label=label_id, + id=ann_id, + attributes=attributes, + group=group, + object_id=object_id, + ) ) elif ann_type == AnnotationType.mask: @@ -223,6 +247,7 @@ def _load_annotations(self, item: Dict): id=ann_id, attributes=attributes, group=group, + object_id=object_id, z_order=z_order, ) ) @@ -235,6 +260,7 @@ def _load_annotations(self, item: Dict): id=ann_id, attributes=attributes, group=group, + object_id=object_id, z_order=z_order, ) ) @@ -247,6 +273,7 @@ def _load_annotations(self, item: Dict): id=ann_id, attributes=attributes, group=group, + object_id=object_id, z_order=z_order, ) ) @@ -263,6 +290,7 @@ def _load_annotations(self, item: Dict): id=ann_id, attributes=attributes, group=group, + object_id=object_id, z_order=z_order, ) ) @@ -275,6 +303,7 @@ def _load_annotations(self, item: Dict): id=ann_id, attributes=attributes, group=group, + object_id=object_id, z_order=z_order, ) ) @@ -293,6 +322,7 @@ def _load_annotations(self, item: Dict): id=ann_id, attributes=attributes, group=group, + object_id=object_id, ) ) @@ -304,6 +334,7 @@ def _load_annotations(self, item: Dict): id=ann_id, attributes=attributes, group=group, + object_id=object_id, z_order=z_order, ) ) @@ -334,9 +365,10 @@ def __init__( rootpath: str, images_dir: str, pcd_dir: str, + video_dir: str, ctx: ImportContext, ) -> None: - super().__init__(path, subset, rootpath, images_dir, pcd_dir, ctx) + super().__init__(path, subset, rootpath, images_dir, pcd_dir, video_dir, ctx) self._length = None def __len__(self): @@ -458,6 +490,11 @@ def _init_path(self, path: str): pcd_dir = osp.join(rootpath, DatumaroPath.PCD_DIR) self._pcd_dir = pcd_dir + video_dir = "" + if rootpath and osp.isdir(osp.join(rootpath, DatumaroPath.VIDEO_DIR)): + video_dir = osp.join(rootpath, DatumaroPath.VIDEO_DIR) + self._video_dir = video_dir + @property def is_stream(self) -> bool: return self._stream @@ -480,6 +517,7 @@ def _load_impl(self, path: str) -> None: self._rootpath, self._images_dir, self._pcd_dir, + self._video_dir, self._ctx, ) if not self._stream @@ -489,6 +527,7 @@ def _load_impl(self, path: str) -> None: self._rootpath, self._images_dir, self._pcd_dir, + self._video_dir, self._ctx, ) ) diff --git a/src/datumaro/plugins/data_formats/datumaro/exporter.py b/src/datumaro/plugins/data_formats/datumaro/exporter.py index 42dae465e6..80cebbed19 100644 --- a/src/datumaro/plugins/data_formats/datumaro/exporter.py +++ b/src/datumaro/plugins/data_formats/datumaro/exporter.py @@ -38,7 +38,7 @@ from datumaro.components.dataset_base import DatasetItem from datumaro.components.dataset_item_storage import ItemStatus from datumaro.components.exporter import ExportContextComponent, Exporter -from datumaro.components.media import Image, MediaElement, PointCloud +from datumaro.components.media import Image, MediaElement, PointCloud, Video, VideoFrame from datumaro.util import cast, dump_json_file from .format import DATUMARO_FORMAT_VERSION, DatumaroPath @@ -97,6 +97,17 @@ def context_save_media( """ if item.media is None: yield + elif isinstance(item.media, VideoFrame): + video_frame = item.media_as(VideoFrame) + + if context.save_media: + fname = context.make_video_filename(item) + if not osp.exists(fname): + context.save_video(item, fname=fname) + item.media = VideoFrame(Video(fname), video_frame.index) + + yield + item.media = video_frame elif isinstance(item.media, Image): image = item.media_as(Image) @@ -145,7 +156,14 @@ def _gen_item_desc(self, item: DatasetItem, *args, **kwargs) -> Dict: item_desc["attr"] = item.attributes with self.context_save_media(item, self.export_context): - if isinstance(item.media, Image): + # Since VideoFrame is a descendant of Image, this condition should be ahead of Image + if isinstance(item.media, VideoFrame): + video_frame = item.media_as(VideoFrame) + item_desc["video_frame"] = { + "video_path": getattr(video_frame.video, "path", None), + "frame_index": getattr(video_frame, "index", -1), + } + elif isinstance(item.media, Image): image = item.media_as(Image) item_desc["image"] = {"path": getattr(image, "path", None)} if item.media.has_size: # avoid occasional loading @@ -221,6 +239,8 @@ def _convert_annotation(self, obj): "attributes": obj.attributes, "group": cast(obj.group, int, 0), } + if obj.object_id >= 0: + ann_json["object_id"] = cast(obj.object_id, int) return ann_json def _convert_label_object(self, obj): @@ -422,12 +442,14 @@ def create_writer( subset: str, images_dir: str, pcd_dir: str, + video_dir: str, ) -> _SubsetWriter: export_context = ExportContextComponent( save_dir=self._save_dir, save_media=self._save_media, images_dir=images_dir, pcd_dir=pcd_dir, + video_dir=video_dir, crypter=NULL_CRYPTER, image_ext=self._image_ext, default_image_ext=self._default_image_ext, @@ -461,12 +483,14 @@ def _apply_impl(self, pool: Optional[Pool] = None, *args, **kwargs): self._annotations_dir = annotations_dir self._pcd_dir = osp.join(self._save_dir, self.PATH_CLS.PCD_DIR) + self._video_dir = osp.join(self._save_dir, self.PATH_CLS.VIDEO_DIR) writers = { subset: self.create_writer( subset, self._images_dir, self._pcd_dir, + self._video_dir, ) for subset in self._extractor.subsets() } diff --git a/src/datumaro/plugins/data_formats/datumaro/format.py b/src/datumaro/plugins/data_formats/datumaro/format.py index 89e652b8f9..470fd21a8f 100644 --- a/src/datumaro/plugins/data_formats/datumaro/format.py +++ b/src/datumaro/plugins/data_formats/datumaro/format.py @@ -9,6 +9,7 @@ class DatumaroPath: IMAGES_DIR = "images" ANNOTATIONS_DIR = "annotations" PCD_DIR = "point_clouds" + VIDEO_DIR = "videos" MASKS_DIR = "masks" ANNOTATION_EXT = ".json" diff --git a/src/datumaro/plugins/data_formats/datumaro_binary/base.py b/src/datumaro/plugins/data_formats/datumaro_binary/base.py index 37e9d9b0d6..54d4809d8d 100644 --- a/src/datumaro/plugins/data_formats/datumaro_binary/base.py +++ b/src/datumaro/plugins/data_formats/datumaro_binary/base.py @@ -12,7 +12,7 @@ from datumaro.components.dataset_base import DatasetItem from datumaro.components.errors import DatasetImportError from datumaro.components.importer import ImportContext -from datumaro.components.media import Image, MediaElement, MediaType, PointCloud +from datumaro.components.media import Image, MediaElement, MediaType, PointCloud, VideoFrame from datumaro.plugins.data_formats.datumaro_binary.format import DatumaroBinaryPath from datumaro.plugins.data_formats.datumaro_binary.mapper import DictMapper from datumaro.plugins.data_formats.datumaro_binary.mapper.common import IntListMapper @@ -108,6 +108,8 @@ def _read_media_type(self): self._media_type = Image elif media_type == MediaType.POINT_CLOUD: self._media_type = PointCloud + elif media_type == MediaType.VIDEO_FRAME: + self._media_type = VideoFrame elif media_type == MediaType.MEDIA_ELEMENT: self._media_type = MediaElement else: @@ -121,6 +123,7 @@ def _read_items(self) -> None: media_path_prefix = { MediaType.IMAGE: osp.join(self._images_dir, self._subset), MediaType.POINT_CLOUD: osp.join(self._pcd_dir, self._subset), + MediaType.VIDEO_FRAME: self._video_dir, } if self._num_workers > 0: diff --git a/src/datumaro/plugins/data_formats/datumaro_binary/exporter.py b/src/datumaro/plugins/data_formats/datumaro_binary/exporter.py index 2b894c2d09..52cf41067d 100644 --- a/src/datumaro/plugins/data_formats/datumaro_binary/exporter.py +++ b/src/datumaro/plugins/data_formats/datumaro_binary/exporter.py @@ -297,12 +297,15 @@ def __init__( ctx=ctx, ) - def create_writer(self, subset: str, images_dir: str, pcd_dir: str) -> _SubsetWriter: + def create_writer( + self, subset: str, images_dir: str, pcd_dir: str, video_dir: str + ) -> _SubsetWriter: export_context = ExportContextComponent( save_dir=self._save_dir, save_media=self._save_media, images_dir=images_dir, pcd_dir=pcd_dir, + video_dir=video_dir, crypter=self._crypter, image_ext=self._image_ext, default_image_ext=self._default_image_ext, diff --git a/src/datumaro/plugins/data_formats/datumaro_binary/format.py b/src/datumaro/plugins/data_formats/datumaro_binary/format.py index 6bf058bbbc..62719b613f 100644 --- a/src/datumaro/plugins/data_formats/datumaro_binary/format.py +++ b/src/datumaro/plugins/data_formats/datumaro_binary/format.py @@ -11,6 +11,7 @@ class DatumaroBinaryPath: IMAGES_DIR = "images" ANNOTATIONS_DIR = "annotations" PCD_DIR = "point_clouds" + VIDEO_DIR = "videos" MASKS_DIR = "masks" ANNOTATION_EXT = ".datum" diff --git a/src/datumaro/plugins/data_formats/datumaro_binary/mapper/annotation.py b/src/datumaro/plugins/data_formats/datumaro_binary/mapper/annotation.py index b98e549ecd..9551449398 100644 --- a/src/datumaro/plugins/data_formats/datumaro_binary/mapper/annotation.py +++ b/src/datumaro/plugins/data_formats/datumaro_binary/mapper/annotation.py @@ -34,16 +34,16 @@ class AnnotationMapper(Mapper): @classmethod def forward(cls, ann: Annotation) -> bytes: _bytearray = bytearray() - _bytearray.extend(struct.pack(" Tuple[Dict, int]: - _, id, group = struct.unpack_from(" Tuple[Annotation, int]: diff --git a/src/datumaro/plugins/data_formats/datumaro_binary/mapper/media.py b/src/datumaro/plugins/data_formats/datumaro_binary/mapper/media.py index 8ed6994cb6..3570cc883c 100644 --- a/src/datumaro/plugins/data_formats/datumaro_binary/mapper/media.py +++ b/src/datumaro/plugins/data_formats/datumaro_binary/mapper/media.py @@ -7,7 +7,7 @@ from typing import Dict, Optional, Tuple from datumaro.components.errors import DatumaroError -from datumaro.components.media import Image, MediaElement, MediaType, PointCloud +from datumaro.components.media import Image, MediaElement, MediaType, PointCloud, Video, VideoFrame from .common import Mapper, StringMapper @@ -21,6 +21,8 @@ def forward(cls, obj: Optional[MediaElement]) -> bytes: return ImageMapper.forward(obj) elif obj._type == MediaType.POINT_CLOUD: return PointCloudMapper.forward(obj) + elif obj._type == MediaType.VIDEO_FRAME: + return VideoFrameMapper.forward(obj) elif obj._type == MediaType.MEDIA_ELEMENT: return MediaElementMapper.forward(obj) else: @@ -41,6 +43,8 @@ def backward( return ImageMapper.backward(_bytes, offset, media_path_prefix) elif media_type == MediaType.POINT_CLOUD: return PointCloudMapper.backward(_bytes, offset, media_path_prefix) + elif media_type == MediaType.VIDEO_FRAME: + return VideoFrameMapper.backward(_bytes, offset, media_path_prefix) elif media_type == MediaType.MEDIA_ELEMENT: return MediaElementMapper.backward(_bytes, offset, media_path_prefix) else: @@ -124,6 +128,34 @@ def backward( ) +class VideoFrameMapper(MediaElementMapper): + MEDIA_TYPE = MediaType.VIDEO_FRAME + + @classmethod + def forward(cls, obj: VideoFrame) -> bytes: + bytes_arr = bytearray() + bytes_arr.extend(super().forward(obj)) + bytes_arr.extend(struct.pack(" Tuple[VideoFrame, int]: + media_dict, offset = cls.backward_dict(_bytes, offset, media_path_prefix) + (frame_index,) = struct.unpack_from("