mapillary-tools 0.14.0a1__py3-none-any.whl → 0.14.0a2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mapillary_tools/__init__.py +1 -1
- mapillary_tools/api_v4.py +4 -4
- mapillary_tools/camm/camm_parser.py +5 -5
- mapillary_tools/commands/__main__.py +1 -2
- mapillary_tools/config.py +7 -5
- mapillary_tools/constants.py +1 -2
- mapillary_tools/exceptions.py +1 -1
- mapillary_tools/exif_read.py +65 -65
- mapillary_tools/exif_write.py +7 -7
- mapillary_tools/exiftool_read.py +23 -46
- mapillary_tools/exiftool_read_video.py +36 -34
- mapillary_tools/ffmpeg.py +24 -23
- mapillary_tools/geo.py +4 -21
- mapillary_tools/geotag/{geotag_from_generic.py → base.py} +32 -48
- mapillary_tools/geotag/factory.py +27 -34
- mapillary_tools/geotag/geotag_images_from_exif.py +15 -51
- mapillary_tools/geotag/geotag_images_from_exiftool.py +107 -59
- mapillary_tools/geotag/geotag_images_from_gpx.py +20 -10
- mapillary_tools/geotag/geotag_images_from_gpx_file.py +2 -34
- mapillary_tools/geotag/geotag_images_from_nmea_file.py +0 -3
- mapillary_tools/geotag/geotag_images_from_video.py +16 -14
- mapillary_tools/geotag/geotag_videos_from_exiftool.py +97 -0
- mapillary_tools/geotag/geotag_videos_from_gpx.py +14 -115
- mapillary_tools/geotag/geotag_videos_from_video.py +14 -147
- mapillary_tools/geotag/image_extractors/base.py +18 -0
- mapillary_tools/geotag/image_extractors/exif.py +60 -0
- mapillary_tools/geotag/image_extractors/exiftool.py +18 -0
- mapillary_tools/geotag/options.py +1 -0
- mapillary_tools/geotag/utils.py +62 -0
- mapillary_tools/geotag/video_extractors/base.py +18 -0
- mapillary_tools/geotag/video_extractors/exiftool.py +70 -0
- mapillary_tools/{video_data_extraction/extractors/gpx_parser.py → geotag/video_extractors/gpx.py} +57 -39
- mapillary_tools/geotag/video_extractors/native.py +157 -0
- mapillary_tools/gpmf/gpmf_parser.py +16 -16
- mapillary_tools/gpmf/gps_filter.py +5 -3
- mapillary_tools/history.py +4 -2
- mapillary_tools/mp4/construct_mp4_parser.py +9 -8
- mapillary_tools/mp4/mp4_sample_parser.py +27 -27
- mapillary_tools/mp4/simple_mp4_builder.py +10 -9
- mapillary_tools/mp4/simple_mp4_parser.py +13 -12
- mapillary_tools/process_geotag_properties.py +5 -7
- mapillary_tools/process_sequence_properties.py +40 -38
- mapillary_tools/sample_video.py +8 -8
- mapillary_tools/telemetry.py +6 -5
- mapillary_tools/types.py +33 -38
- mapillary_tools/utils.py +16 -18
- {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/METADATA +1 -1
- mapillary_tools-0.14.0a2.dist-info/RECORD +72 -0
- mapillary_tools/geotag/__init__.py +0 -1
- mapillary_tools/geotag/geotag_images_from_exiftool_both_image_and_video.py +0 -77
- mapillary_tools/geotag/geotag_videos_from_exiftool_video.py +0 -151
- mapillary_tools/video_data_extraction/cli_options.py +0 -22
- mapillary_tools/video_data_extraction/extract_video_data.py +0 -157
- mapillary_tools/video_data_extraction/extractors/base_parser.py +0 -75
- mapillary_tools/video_data_extraction/extractors/blackvue_parser.py +0 -49
- mapillary_tools/video_data_extraction/extractors/camm_parser.py +0 -62
- mapillary_tools/video_data_extraction/extractors/exiftool_runtime_parser.py +0 -74
- mapillary_tools/video_data_extraction/extractors/exiftool_xml_parser.py +0 -52
- mapillary_tools/video_data_extraction/extractors/generic_video_parser.py +0 -52
- mapillary_tools/video_data_extraction/extractors/gopro_parser.py +0 -58
- mapillary_tools/video_data_extraction/extractors/nmea_parser.py +0 -24
- mapillary_tools/video_data_extraction/video_data_parser_factory.py +0 -39
- mapillary_tools-0.14.0a1.dist-info/RECORD +0 -78
- {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/WHEEL +0 -0
- {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/entry_points.txt +0 -0
- {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/licenses/LICENSE +0 -0
- {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ... import types
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseImageExtractor(abc.ABC):
|
|
10
|
+
"""
|
|
11
|
+
Extracts metadata from an image file.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, image_path: Path):
|
|
15
|
+
self.image_path = image_path
|
|
16
|
+
|
|
17
|
+
def extract(self) -> types.ImageMetadataOrError:
|
|
18
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import sys
|
|
5
|
+
import typing as T
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
if sys.version_info >= (3, 12):
|
|
9
|
+
from typing import override
|
|
10
|
+
else:
|
|
11
|
+
from typing_extensions import override
|
|
12
|
+
|
|
13
|
+
from ... import exceptions, exif_read, geo, types, utils
|
|
14
|
+
from .base import BaseImageExtractor
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ImageEXIFExtractor(BaseImageExtractor):
|
|
18
|
+
def __init__(self, image_path: Path, skip_lonlat_error: bool = False):
|
|
19
|
+
super().__init__(image_path)
|
|
20
|
+
self.skip_lonlat_error = skip_lonlat_error
|
|
21
|
+
|
|
22
|
+
@contextlib.contextmanager
|
|
23
|
+
def _exif_context(self) -> T.Generator[exif_read.ExifReadABC, None, None]:
|
|
24
|
+
with self.image_path.open("rb") as fp:
|
|
25
|
+
yield exif_read.ExifRead(fp)
|
|
26
|
+
|
|
27
|
+
@override
|
|
28
|
+
def extract(self) -> types.ImageMetadata:
|
|
29
|
+
with self._exif_context() as exif:
|
|
30
|
+
lonlat = exif.extract_lon_lat()
|
|
31
|
+
if lonlat is None:
|
|
32
|
+
if not self.skip_lonlat_error:
|
|
33
|
+
raise exceptions.MapillaryGeoTaggingError(
|
|
34
|
+
"Unable to extract GPS Longitude or GPS Latitude from the image"
|
|
35
|
+
)
|
|
36
|
+
lonlat = (0.0, 0.0)
|
|
37
|
+
lon, lat = lonlat
|
|
38
|
+
|
|
39
|
+
capture_time = exif.extract_capture_time()
|
|
40
|
+
if capture_time is None:
|
|
41
|
+
raise exceptions.MapillaryGeoTaggingError(
|
|
42
|
+
"Unable to extract timestamp from the image"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
image_metadata = types.ImageMetadata(
|
|
46
|
+
filename=self.image_path,
|
|
47
|
+
filesize=utils.get_file_size(self.image_path),
|
|
48
|
+
time=geo.as_unix_time(capture_time),
|
|
49
|
+
lat=lat,
|
|
50
|
+
lon=lon,
|
|
51
|
+
alt=exif.extract_altitude(),
|
|
52
|
+
angle=exif.extract_direction(),
|
|
53
|
+
width=exif.extract_width(),
|
|
54
|
+
height=exif.extract_height(),
|
|
55
|
+
MAPOrientation=exif.extract_orientation(),
|
|
56
|
+
MAPDeviceMake=exif.extract_make(),
|
|
57
|
+
MAPDeviceModel=exif.extract_model(),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return image_metadata
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import xml.etree.ElementTree as ET
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ... import exiftool_read
|
|
8
|
+
from .exif import ImageEXIFExtractor
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ImageExifToolExtractor(ImageEXIFExtractor):
|
|
12
|
+
def __init__(self, image_path: Path, element: ET.Element):
|
|
13
|
+
super().__init__(image_path)
|
|
14
|
+
self.element = element
|
|
15
|
+
|
|
16
|
+
@contextlib.contextmanager
|
|
17
|
+
def _exif_context(self):
|
|
18
|
+
yield exiftool_read.ExifToolRead(ET.ElementTree(self.element))
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import typing as T
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import gpxpy
|
|
9
|
+
|
|
10
|
+
from .. import exiftool_read, geo, utils
|
|
11
|
+
|
|
12
|
+
Track = T.List[geo.Point]
|
|
13
|
+
LOG = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_gpx(gpx_file: Path) -> list[Track]:
|
|
17
|
+
with gpx_file.open("r") as f:
|
|
18
|
+
gpx = gpxpy.parse(f)
|
|
19
|
+
|
|
20
|
+
tracks: list[Track] = []
|
|
21
|
+
|
|
22
|
+
for track in gpx.tracks:
|
|
23
|
+
for segment in track.segments:
|
|
24
|
+
tracks.append([])
|
|
25
|
+
for point in segment.points:
|
|
26
|
+
if point.time is not None:
|
|
27
|
+
tracks[-1].append(
|
|
28
|
+
geo.Point(
|
|
29
|
+
time=geo.as_unix_time(point.time),
|
|
30
|
+
lat=point.latitude,
|
|
31
|
+
lon=point.longitude,
|
|
32
|
+
alt=point.elevation,
|
|
33
|
+
angle=None,
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
return tracks
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def index_rdf_description_by_path(
|
|
41
|
+
xml_paths: T.Sequence[Path],
|
|
42
|
+
) -> dict[str, ET.Element]:
|
|
43
|
+
rdf_description_by_path: dict[str, ET.Element] = {}
|
|
44
|
+
|
|
45
|
+
for xml_path in utils.find_xml_files(xml_paths):
|
|
46
|
+
try:
|
|
47
|
+
etree = ET.parse(xml_path)
|
|
48
|
+
except ET.ParseError as ex:
|
|
49
|
+
verbose = LOG.getEffectiveLevel() <= logging.DEBUG
|
|
50
|
+
if verbose:
|
|
51
|
+
LOG.warning("Failed to parse %s", xml_path, exc_info=True)
|
|
52
|
+
else:
|
|
53
|
+
LOG.warning("Failed to parse %s: %s", xml_path, ex)
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
rdf_description_by_path.update(
|
|
57
|
+
exiftool_read.index_rdf_description_by_path_from_xml_element(
|
|
58
|
+
etree.getroot()
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return rdf_description_by_path
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ... import types
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseVideoExtractor(abc.ABC):
|
|
10
|
+
"""
|
|
11
|
+
Extracts metadata from a video file.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, video_path: Path):
|
|
15
|
+
self.video_path = video_path
|
|
16
|
+
|
|
17
|
+
def extract(self) -> types.VideoMetadataOrError:
|
|
18
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import typing as T
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from xml.etree import ElementTree as ET
|
|
7
|
+
|
|
8
|
+
if sys.version_info >= (3, 12):
|
|
9
|
+
from typing import override
|
|
10
|
+
else:
|
|
11
|
+
from typing_extensions import override
|
|
12
|
+
|
|
13
|
+
from ... import exceptions, exiftool_read_video, geo, telemetry, types, utils
|
|
14
|
+
from ...gpmf import gpmf_gps_filter
|
|
15
|
+
from .base import BaseVideoExtractor
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class VideoExifToolExtractor(BaseVideoExtractor):
|
|
19
|
+
def __init__(self, video_path: Path, element: ET.Element):
|
|
20
|
+
super().__init__(video_path)
|
|
21
|
+
self.element = element
|
|
22
|
+
|
|
23
|
+
@override
|
|
24
|
+
def extract(self) -> types.VideoMetadataOrError:
|
|
25
|
+
exif = exiftool_read_video.ExifToolReadVideo(ET.ElementTree(self.element))
|
|
26
|
+
|
|
27
|
+
make = exif.extract_make()
|
|
28
|
+
model = exif.extract_model()
|
|
29
|
+
|
|
30
|
+
is_gopro = make is not None and make.upper() in ["GOPRO"]
|
|
31
|
+
|
|
32
|
+
points = exif.extract_gps_track()
|
|
33
|
+
|
|
34
|
+
# ExifTool has no idea if GPS is not found or found but empty
|
|
35
|
+
if is_gopro:
|
|
36
|
+
if not points:
|
|
37
|
+
raise exceptions.MapillaryGPXEmptyError("Empty GPS data found")
|
|
38
|
+
|
|
39
|
+
# ExifTool (since 13.04) converts GPSSpeed for GoPro to km/h, so here we convert it back to m/s
|
|
40
|
+
for p in points:
|
|
41
|
+
if isinstance(p, telemetry.GPSPoint) and p.ground_speed is not None:
|
|
42
|
+
p.ground_speed = p.ground_speed / 3.6
|
|
43
|
+
|
|
44
|
+
if isinstance(points[0], telemetry.GPSPoint):
|
|
45
|
+
points = T.cast(
|
|
46
|
+
T.List[geo.Point],
|
|
47
|
+
gpmf_gps_filter.remove_noisy_points(
|
|
48
|
+
T.cast(T.List[telemetry.GPSPoint], points)
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
if not points:
|
|
52
|
+
raise exceptions.MapillaryGPSNoiseError("GPS is too noisy")
|
|
53
|
+
|
|
54
|
+
if not points:
|
|
55
|
+
raise exceptions.MapillaryVideoGPSNotFoundError(
|
|
56
|
+
"No GPS data found from the video"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
filetype = types.FileType.GOPRO if is_gopro else types.FileType.VIDEO
|
|
60
|
+
|
|
61
|
+
video_metadata = types.VideoMetadata(
|
|
62
|
+
self.video_path,
|
|
63
|
+
filesize=utils.get_file_size(self.video_path),
|
|
64
|
+
filetype=filetype,
|
|
65
|
+
points=points,
|
|
66
|
+
make=make,
|
|
67
|
+
model=model,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return video_metadata
|
mapillary_tools/{video_data_extraction/extractors/gpx_parser.py → geotag/video_extractors/gpx.py}
RENAMED
|
@@ -1,51 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
1
4
|
import datetime
|
|
2
5
|
import logging
|
|
6
|
+
import sys
|
|
3
7
|
import typing as T
|
|
8
|
+
from pathlib import Path
|
|
4
9
|
|
|
5
|
-
|
|
6
|
-
from
|
|
7
|
-
|
|
8
|
-
from
|
|
10
|
+
if sys.version_info >= (3, 12):
|
|
11
|
+
from typing import override
|
|
12
|
+
else:
|
|
13
|
+
from typing_extensions import override
|
|
9
14
|
|
|
15
|
+
from ... import geo, telemetry, types
|
|
16
|
+
from ..utils import parse_gpx
|
|
17
|
+
from .base import BaseVideoExtractor
|
|
18
|
+
from .native import NativeVideoExtractor
|
|
10
19
|
|
|
11
|
-
LOG = logging.getLogger(__name__)
|
|
12
20
|
|
|
21
|
+
LOG = logging.getLogger(__name__)
|
|
13
22
|
|
|
14
|
-
class GpxParser(BaseParser):
|
|
15
|
-
default_source_pattern = "%g.gpx"
|
|
16
|
-
parser_label = "gpx"
|
|
17
23
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
class GPXVideoExtractor(BaseVideoExtractor):
|
|
25
|
+
def __init__(self, video_path: Path, gpx_path: Path):
|
|
26
|
+
self.video_path = video_path
|
|
27
|
+
self.gpx_path = gpx_path
|
|
22
28
|
|
|
29
|
+
@override
|
|
30
|
+
def extract(self) -> types.VideoMetadataOrError:
|
|
23
31
|
try:
|
|
24
|
-
gpx_tracks =
|
|
32
|
+
gpx_tracks = parse_gpx(self.gpx_path)
|
|
25
33
|
except Exception as ex:
|
|
26
34
|
raise RuntimeError(
|
|
27
|
-
f"Error parsing GPX {
|
|
35
|
+
f"Error parsing GPX {self.gpx_path}: {ex.__class__.__name__}: {ex}"
|
|
28
36
|
)
|
|
29
37
|
|
|
30
38
|
if 1 < len(gpx_tracks):
|
|
31
39
|
LOG.warning(
|
|
32
40
|
"Found %s tracks in the GPX file %s. Will merge points in all the tracks as a single track for interpolation",
|
|
33
41
|
len(gpx_tracks),
|
|
34
|
-
self.
|
|
42
|
+
self.gpx_path,
|
|
35
43
|
)
|
|
36
44
|
|
|
37
45
|
gpx_points: T.Sequence[geo.Point] = sum(gpx_tracks, [])
|
|
38
|
-
if not gpx_points:
|
|
39
|
-
return gpx_points
|
|
40
46
|
|
|
41
|
-
|
|
47
|
+
native_extractor = NativeVideoExtractor(self.video_path)
|
|
48
|
+
|
|
49
|
+
video_metadata_or_error = native_extractor.extract()
|
|
50
|
+
|
|
51
|
+
if isinstance(video_metadata_or_error, types.ErrorMetadata):
|
|
52
|
+
self._rebase_times(gpx_points)
|
|
53
|
+
return types.VideoMetadata(
|
|
54
|
+
filename=video_metadata_or_error.filename,
|
|
55
|
+
filetype=video_metadata_or_error.filetype or types.FileType.VIDEO,
|
|
56
|
+
points=gpx_points,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
video_metadata = video_metadata_or_error
|
|
60
|
+
|
|
61
|
+
offset = self._synx_gpx_by_first_gps_timestamp(
|
|
62
|
+
gpx_points, video_metadata.points
|
|
63
|
+
)
|
|
42
64
|
|
|
43
65
|
self._rebase_times(gpx_points, offset=offset)
|
|
44
66
|
|
|
45
|
-
return gpx_points
|
|
67
|
+
return dataclasses.replace(video_metadata_or_error, points=gpx_points)
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _rebase_times(points: T.Sequence[geo.Point], offset: float = 0.0):
|
|
71
|
+
"""
|
|
72
|
+
Make point times start from 0
|
|
73
|
+
"""
|
|
74
|
+
if points:
|
|
75
|
+
first_timestamp = points[0].time
|
|
76
|
+
for p in points:
|
|
77
|
+
p.time = (p.time - first_timestamp) + offset
|
|
78
|
+
return points
|
|
46
79
|
|
|
47
80
|
def _synx_gpx_by_first_gps_timestamp(
|
|
48
|
-
self, gpx_points: T.Sequence[geo.Point]
|
|
81
|
+
self, gpx_points: T.Sequence[geo.Point], video_gps_points: T.Sequence[geo.Point]
|
|
49
82
|
) -> float:
|
|
50
83
|
offset: float = 0.0
|
|
51
84
|
|
|
@@ -57,19 +90,14 @@ class GpxParser(BaseParser):
|
|
|
57
90
|
)
|
|
58
91
|
LOG.info("First GPX timestamp: %s", first_gpx_dt)
|
|
59
92
|
|
|
60
|
-
|
|
61
|
-
# Use an empty dictionary to force video parsers to extract make/model from the video metadata itself
|
|
62
|
-
parser = GenericVideoParser(self.videoPath, self.options, {})
|
|
63
|
-
gps_points = parser.extract_points()
|
|
64
|
-
|
|
65
|
-
if not gps_points:
|
|
93
|
+
if not video_gps_points:
|
|
66
94
|
LOG.warning(
|
|
67
95
|
"Skip GPX synchronization because no GPS found in video %s",
|
|
68
|
-
self.
|
|
96
|
+
self.video_path,
|
|
69
97
|
)
|
|
70
98
|
return offset
|
|
71
99
|
|
|
72
|
-
first_gps_point =
|
|
100
|
+
first_gps_point = video_gps_points[0]
|
|
73
101
|
if isinstance(first_gps_point, telemetry.GPSPoint):
|
|
74
102
|
if first_gps_point.epoch_time is not None:
|
|
75
103
|
first_gps_dt = datetime.datetime.fromtimestamp(
|
|
@@ -92,17 +120,7 @@ class GpxParser(BaseParser):
|
|
|
92
120
|
else:
|
|
93
121
|
LOG.warning(
|
|
94
122
|
"Skip GPX synchronization because no GPS epoch time found in video %s",
|
|
95
|
-
self.
|
|
123
|
+
self.video_path,
|
|
96
124
|
)
|
|
97
125
|
|
|
98
126
|
return offset
|
|
99
|
-
|
|
100
|
-
def extract_make(self) -> T.Optional[str]:
|
|
101
|
-
# Use an empty dictionary to force video parsers to extract make/model from the video metadata itself
|
|
102
|
-
parser = GenericVideoParser(self.videoPath, self.options, {})
|
|
103
|
-
return parser.extract_make()
|
|
104
|
-
|
|
105
|
-
def extract_model(self) -> T.Optional[str]:
|
|
106
|
-
# Use an empty dictionary to force video parsers to extract make/model from the video metadata itself
|
|
107
|
-
parser = GenericVideoParser(self.videoPath, self.options, {})
|
|
108
|
-
return parser.extract_model()
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import typing as T
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
if sys.version_info >= (3, 12):
|
|
8
|
+
from typing import override
|
|
9
|
+
else:
|
|
10
|
+
from typing_extensions import override
|
|
11
|
+
|
|
12
|
+
from ... import blackvue_parser, exceptions, geo, telemetry, types, utils
|
|
13
|
+
from ...camm import camm_parser
|
|
14
|
+
from ...gpmf import gpmf_gps_filter, gpmf_parser
|
|
15
|
+
from .base import BaseVideoExtractor
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GoProVideoExtractor(BaseVideoExtractor):
|
|
19
|
+
@override
|
|
20
|
+
def extract(self) -> types.VideoMetadataOrError:
|
|
21
|
+
with self.video_path.open("rb") as fp:
|
|
22
|
+
gopro_info = gpmf_parser.extract_gopro_info(fp)
|
|
23
|
+
|
|
24
|
+
if gopro_info is None:
|
|
25
|
+
raise exceptions.MapillaryVideoGPSNotFoundError(
|
|
26
|
+
"No GPS data found from the video"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
gps_points = gopro_info.gps
|
|
30
|
+
assert gps_points is not None, "must have GPS data extracted"
|
|
31
|
+
if not gps_points:
|
|
32
|
+
# Instead of raising an exception, return error metadata to tell the file type
|
|
33
|
+
ex: exceptions.MapillaryDescriptionError = (
|
|
34
|
+
exceptions.MapillaryGPXEmptyError("Empty GPS data found")
|
|
35
|
+
)
|
|
36
|
+
return types.describe_error_metadata(
|
|
37
|
+
ex, self.video_path, filetype=types.FileType.GOPRO
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
gps_points = T.cast(
|
|
41
|
+
T.List[telemetry.GPSPoint], gpmf_gps_filter.remove_noisy_points(gps_points)
|
|
42
|
+
)
|
|
43
|
+
if not gps_points:
|
|
44
|
+
# Instead of raising an exception, return error metadata to tell the file type
|
|
45
|
+
ex = exceptions.MapillaryGPSNoiseError("GPS is too noisy")
|
|
46
|
+
return types.describe_error_metadata(
|
|
47
|
+
ex, self.video_path, filetype=types.FileType.GOPRO
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
video_metadata = types.VideoMetadata(
|
|
51
|
+
filename=self.video_path,
|
|
52
|
+
filesize=utils.get_file_size(self.video_path),
|
|
53
|
+
filetype=types.FileType.GOPRO,
|
|
54
|
+
points=T.cast(T.List[geo.Point], gps_points),
|
|
55
|
+
make=gopro_info.make,
|
|
56
|
+
model=gopro_info.model,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return video_metadata
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CAMMVideoExtractor(BaseVideoExtractor):
|
|
63
|
+
@override
|
|
64
|
+
def extract(self) -> types.VideoMetadataOrError:
|
|
65
|
+
with self.video_path.open("rb") as fp:
|
|
66
|
+
camm_info = camm_parser.extract_camm_info(fp)
|
|
67
|
+
|
|
68
|
+
if camm_info is None:
|
|
69
|
+
raise exceptions.MapillaryVideoGPSNotFoundError(
|
|
70
|
+
"No GPS data found from the video"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if not camm_info.gps and not camm_info.mini_gps:
|
|
74
|
+
# Instead of raising an exception, return error metadata to tell the file type
|
|
75
|
+
ex: exceptions.MapillaryDescriptionError = (
|
|
76
|
+
exceptions.MapillaryGPXEmptyError("Empty GPS data found")
|
|
77
|
+
)
|
|
78
|
+
return types.describe_error_metadata(
|
|
79
|
+
ex, self.video_path, filetype=types.FileType.CAMM
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return types.VideoMetadata(
|
|
83
|
+
filename=self.video_path,
|
|
84
|
+
filesize=utils.get_file_size(self.video_path),
|
|
85
|
+
filetype=types.FileType.CAMM,
|
|
86
|
+
points=T.cast(T.List[geo.Point], camm_info.gps or camm_info.mini_gps),
|
|
87
|
+
make=camm_info.make,
|
|
88
|
+
model=camm_info.model,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class BlackVueVideoExtractor(BaseVideoExtractor):
|
|
93
|
+
@override
|
|
94
|
+
def extract(self) -> types.VideoMetadataOrError:
|
|
95
|
+
with self.video_path.open("rb") as fp:
|
|
96
|
+
blackvue_info = blackvue_parser.extract_blackvue_info(fp)
|
|
97
|
+
|
|
98
|
+
if blackvue_info is None:
|
|
99
|
+
raise exceptions.MapillaryVideoGPSNotFoundError(
|
|
100
|
+
"No GPS data found from the video"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if not blackvue_info.gps:
|
|
104
|
+
# Instead of raising an exception, return error metadata to tell the file type
|
|
105
|
+
ex: exceptions.MapillaryDescriptionError = (
|
|
106
|
+
exceptions.MapillaryGPXEmptyError("Empty GPS data found")
|
|
107
|
+
)
|
|
108
|
+
return types.describe_error_metadata(
|
|
109
|
+
ex, self.video_path, filetype=types.FileType.BLACKVUE
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
video_metadata = types.VideoMetadata(
|
|
113
|
+
filename=self.video_path,
|
|
114
|
+
filesize=utils.get_file_size(self.video_path),
|
|
115
|
+
filetype=types.FileType.BLACKVUE,
|
|
116
|
+
points=blackvue_info.gps or [],
|
|
117
|
+
make=blackvue_info.make,
|
|
118
|
+
model=blackvue_info.model,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return video_metadata
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class NativeVideoExtractor(BaseVideoExtractor):
|
|
125
|
+
def __init__(self, video_path: Path, filetypes: set[types.FileType] | None = None):
|
|
126
|
+
super().__init__(video_path)
|
|
127
|
+
self.filetypes = filetypes
|
|
128
|
+
|
|
129
|
+
@override
|
|
130
|
+
def extract(self) -> types.VideoMetadataOrError:
|
|
131
|
+
ft = self.filetypes
|
|
132
|
+
extractor: BaseVideoExtractor
|
|
133
|
+
|
|
134
|
+
if ft is None or types.FileType.VIDEO in ft or types.FileType.GOPRO in ft:
|
|
135
|
+
extractor = GoProVideoExtractor(self.video_path)
|
|
136
|
+
try:
|
|
137
|
+
return extractor.extract()
|
|
138
|
+
except exceptions.MapillaryVideoGPSNotFoundError:
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
if ft is None or types.FileType.VIDEO in ft or types.FileType.CAMM in ft:
|
|
142
|
+
extractor = CAMMVideoExtractor(self.video_path)
|
|
143
|
+
try:
|
|
144
|
+
return extractor.extract()
|
|
145
|
+
except exceptions.MapillaryVideoGPSNotFoundError:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
if ft is None or types.FileType.VIDEO in ft or types.FileType.BLACKVUE in ft:
|
|
149
|
+
extractor = BlackVueVideoExtractor(self.video_path)
|
|
150
|
+
try:
|
|
151
|
+
return extractor.extract()
|
|
152
|
+
except exceptions.MapillaryVideoGPSNotFoundError:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
raise exceptions.MapillaryVideoGPSNotFoundError(
|
|
156
|
+
"No GPS data found from the video"
|
|
157
|
+
)
|
|
@@ -39,7 +39,7 @@ class KLVDict(T.TypedDict):
|
|
|
39
39
|
type: bytes
|
|
40
40
|
structure_size: int
|
|
41
41
|
repeat: int
|
|
42
|
-
data:
|
|
42
|
+
data: list[T.Any]
|
|
43
43
|
|
|
44
44
|
|
|
45
45
|
GPMFSampleData: C.GreedyRange
|
|
@@ -143,7 +143,7 @@ class GoProInfo:
|
|
|
143
143
|
|
|
144
144
|
def extract_gopro_info(
|
|
145
145
|
fp: T.BinaryIO, telemetry_only: bool = False
|
|
146
|
-
) ->
|
|
146
|
+
) -> GoProInfo | None:
|
|
147
147
|
"""
|
|
148
148
|
Return the GoProInfo object if found. None indicates it's not a valid GoPro video.
|
|
149
149
|
"""
|
|
@@ -276,7 +276,7 @@ def _gps5_timestamp_to_epoch_time(dtstr: str):
|
|
|
276
276
|
def _gps5_from_stream(
|
|
277
277
|
stream: T.Sequence[KLVDict],
|
|
278
278
|
) -> T.Generator[telemetry.GPSPoint, None, None]:
|
|
279
|
-
indexed:
|
|
279
|
+
indexed: dict[bytes, list[list[T.Any]]] = {
|
|
280
280
|
klv["key"]: klv["data"] for klv in stream
|
|
281
281
|
}
|
|
282
282
|
|
|
@@ -362,7 +362,7 @@ def _gps9_from_stream(
|
|
|
362
362
|
) -> T.Generator[telemetry.GPSPoint, None, None]:
|
|
363
363
|
NUM_VALUES = 9
|
|
364
364
|
|
|
365
|
-
indexed:
|
|
365
|
+
indexed: dict[bytes, list[list[T.Any]]] = {
|
|
366
366
|
klv["key"]: klv["data"] for klv in stream
|
|
367
367
|
}
|
|
368
368
|
|
|
@@ -444,8 +444,8 @@ def _find_first_device_id(stream: T.Sequence[KLVDict]) -> int:
|
|
|
444
444
|
return device_id
|
|
445
445
|
|
|
446
446
|
|
|
447
|
-
def _find_first_gps_stream(stream: T.Sequence[KLVDict]) ->
|
|
448
|
-
sample_points:
|
|
447
|
+
def _find_first_gps_stream(stream: T.Sequence[KLVDict]) -> list[telemetry.GPSPoint]:
|
|
448
|
+
sample_points: list[telemetry.GPSPoint] = []
|
|
449
449
|
|
|
450
450
|
for klv in stream:
|
|
451
451
|
if klv["key"] == b"STRM":
|
|
@@ -469,7 +469,7 @@ def _is_matrix_calibration(matrix: T.Sequence[float]) -> bool:
|
|
|
469
469
|
|
|
470
470
|
|
|
471
471
|
def _build_matrix(
|
|
472
|
-
orin:
|
|
472
|
+
orin: bytes | T.Sequence[int], orio: bytes | T.Sequence[int]
|
|
473
473
|
) -> T.Sequence[float]:
|
|
474
474
|
matrix = []
|
|
475
475
|
|
|
@@ -503,14 +503,14 @@ def _apply_matrix(
|
|
|
503
503
|
yield sum(matrix[row_start + x] * values[x] for x in range(size))
|
|
504
504
|
|
|
505
505
|
|
|
506
|
-
def _flatten(nested: T.Sequence[T.Sequence[float]]) ->
|
|
507
|
-
output:
|
|
506
|
+
def _flatten(nested: T.Sequence[T.Sequence[float]]) -> list[float]:
|
|
507
|
+
output: list[float] = []
|
|
508
508
|
for row in nested:
|
|
509
509
|
output.extend(row)
|
|
510
510
|
return output
|
|
511
511
|
|
|
512
512
|
|
|
513
|
-
def _get_matrix(klv:
|
|
513
|
+
def _get_matrix(klv: dict[bytes, KLVDict]) -> T.Sequence[float] | None:
|
|
514
514
|
mtrx = klv.get(b"MTRX")
|
|
515
515
|
if mtrx is not None:
|
|
516
516
|
matrix: T.Sequence[float] = _flatten(mtrx["data"])
|
|
@@ -530,7 +530,7 @@ def _get_matrix(klv: T.Dict[bytes, KLVDict]) -> T.Optional[T.Sequence[float]]:
|
|
|
530
530
|
def _scale_and_calibrate(
|
|
531
531
|
stream: T.Sequence[KLVDict], key: bytes
|
|
532
532
|
) -> T.Generator[T.Sequence[float], None, None]:
|
|
533
|
-
indexed:
|
|
533
|
+
indexed: dict[bytes, KLVDict] = {klv["key"]: klv for klv in stream}
|
|
534
534
|
|
|
535
535
|
klv = indexed.get(key)
|
|
536
536
|
if klv is None:
|
|
@@ -561,7 +561,7 @@ def _scale_and_calibrate(
|
|
|
561
561
|
|
|
562
562
|
|
|
563
563
|
def _find_first_telemetry_stream(stream: T.Sequence[KLVDict], key: bytes):
|
|
564
|
-
values:
|
|
564
|
+
values: list[T.Sequence[float]] = []
|
|
565
565
|
|
|
566
566
|
for klv in stream:
|
|
567
567
|
if klv["key"] == b"STRM":
|
|
@@ -684,7 +684,7 @@ def _load_telemetry_from_samples(
|
|
|
684
684
|
return device_found
|
|
685
685
|
|
|
686
686
|
|
|
687
|
-
def _is_gpmd_description(description:
|
|
687
|
+
def _is_gpmd_description(description: dict) -> bool:
|
|
688
688
|
return description["format"] == b"gpmd"
|
|
689
689
|
|
|
690
690
|
|
|
@@ -699,11 +699,11 @@ def _filter_gpmd_samples(track: TrackBoxParser) -> T.Generator[Sample, None, Non
|
|
|
699
699
|
yield sample
|
|
700
700
|
|
|
701
701
|
|
|
702
|
-
def _extract_camera_model_from_devices(device_names:
|
|
702
|
+
def _extract_camera_model_from_devices(device_names: dict[int, bytes]) -> str:
|
|
703
703
|
if not device_names:
|
|
704
704
|
return ""
|
|
705
705
|
|
|
706
|
-
unicode_names:
|
|
706
|
+
unicode_names: list[str] = []
|
|
707
707
|
for name in device_names.values():
|
|
708
708
|
try:
|
|
709
709
|
unicode_names.append(name.decode("utf-8"))
|
|
@@ -730,7 +730,7 @@ def _extract_camera_model_from_devices(device_names: T.Dict[int, bytes]) -> str:
|
|
|
730
730
|
|
|
731
731
|
def _iterate_read_sample_data(
|
|
732
732
|
fp: T.BinaryIO, samples: T.Iterable[Sample]
|
|
733
|
-
) -> T.Generator[
|
|
733
|
+
) -> T.Generator[tuple[Sample, bytes], None, None]:
|
|
734
734
|
for sample in samples:
|
|
735
735
|
fp.seek(sample.raw_sample.offset, io.SEEK_SET)
|
|
736
736
|
yield (sample, fp.read(sample.raw_sample.size))
|