mapillary-tools 0.13.3__py3-none-any.whl → 0.14.0__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 +198 -55
- mapillary_tools/authenticate.py +326 -64
- mapillary_tools/blackvue_parser.py +195 -0
- mapillary_tools/camm/camm_builder.py +55 -97
- mapillary_tools/camm/camm_parser.py +429 -181
- mapillary_tools/commands/__main__.py +10 -6
- mapillary_tools/commands/authenticate.py +8 -1
- mapillary_tools/commands/process.py +27 -51
- mapillary_tools/commands/process_and_upload.py +18 -5
- mapillary_tools/commands/sample_video.py +2 -3
- mapillary_tools/commands/upload.py +44 -13
- mapillary_tools/commands/video_process_and_upload.py +19 -5
- mapillary_tools/config.py +65 -26
- mapillary_tools/constants.py +141 -18
- mapillary_tools/exceptions.py +37 -34
- mapillary_tools/exif_read.py +221 -116
- mapillary_tools/exif_write.py +10 -8
- mapillary_tools/exiftool_read.py +33 -42
- mapillary_tools/exiftool_read_video.py +97 -47
- mapillary_tools/exiftool_runner.py +57 -0
- mapillary_tools/ffmpeg.py +417 -242
- mapillary_tools/geo.py +158 -118
- mapillary_tools/geotag/__init__.py +0 -1
- mapillary_tools/geotag/base.py +147 -0
- mapillary_tools/geotag/factory.py +307 -0
- mapillary_tools/geotag/geotag_images_from_exif.py +14 -131
- mapillary_tools/geotag/geotag_images_from_exiftool.py +136 -85
- mapillary_tools/geotag/geotag_images_from_gpx.py +60 -124
- mapillary_tools/geotag/geotag_images_from_gpx_file.py +13 -126
- mapillary_tools/geotag/geotag_images_from_nmea_file.py +4 -5
- mapillary_tools/geotag/geotag_images_from_video.py +88 -51
- mapillary_tools/geotag/geotag_videos_from_exiftool.py +123 -0
- mapillary_tools/geotag/geotag_videos_from_gpx.py +52 -0
- mapillary_tools/geotag/geotag_videos_from_video.py +20 -185
- 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 +182 -0
- mapillary_tools/geotag/utils.py +52 -16
- mapillary_tools/geotag/video_extractors/base.py +18 -0
- mapillary_tools/geotag/video_extractors/exiftool.py +70 -0
- mapillary_tools/geotag/video_extractors/gpx.py +116 -0
- mapillary_tools/geotag/video_extractors/native.py +160 -0
- mapillary_tools/{geotag → gpmf}/gpmf_parser.py +205 -182
- mapillary_tools/{geotag → gpmf}/gps_filter.py +5 -3
- mapillary_tools/history.py +134 -20
- mapillary_tools/mp4/construct_mp4_parser.py +17 -10
- mapillary_tools/mp4/io_utils.py +0 -1
- mapillary_tools/mp4/mp4_sample_parser.py +36 -28
- mapillary_tools/mp4/simple_mp4_builder.py +10 -9
- mapillary_tools/mp4/simple_mp4_parser.py +13 -22
- mapillary_tools/process_geotag_properties.py +184 -414
- mapillary_tools/process_sequence_properties.py +594 -225
- mapillary_tools/sample_video.py +20 -26
- mapillary_tools/serializer/description.py +587 -0
- mapillary_tools/serializer/gpx.py +132 -0
- mapillary_tools/telemetry.py +26 -13
- mapillary_tools/types.py +98 -611
- mapillary_tools/upload.py +411 -387
- mapillary_tools/upload_api_v4.py +167 -142
- mapillary_tools/uploader.py +804 -284
- mapillary_tools/utils.py +49 -18
- {mapillary_tools-0.13.3.dist-info → mapillary_tools-0.14.0.dist-info}/METADATA +93 -35
- mapillary_tools-0.14.0.dist-info/RECORD +75 -0
- {mapillary_tools-0.13.3.dist-info → mapillary_tools-0.14.0.dist-info}/WHEEL +1 -1
- mapillary_tools/geotag/blackvue_parser.py +0 -118
- mapillary_tools/geotag/geotag_from_generic.py +0 -22
- mapillary_tools/geotag/geotag_images_from_exiftool_both_image_and_video.py +0 -93
- mapillary_tools/geotag/geotag_videos_from_exiftool_video.py +0 -145
- mapillary_tools/video_data_extraction/cli_options.py +0 -22
- mapillary_tools/video_data_extraction/extract_video_data.py +0 -176
- mapillary_tools/video_data_extraction/extractors/base_parser.py +0 -75
- mapillary_tools/video_data_extraction/extractors/blackvue_parser.py +0 -34
- mapillary_tools/video_data_extraction/extractors/camm_parser.py +0 -38
- mapillary_tools/video_data_extraction/extractors/exiftool_runtime_parser.py +0 -71
- mapillary_tools/video_data_extraction/extractors/exiftool_xml_parser.py +0 -53
- mapillary_tools/video_data_extraction/extractors/generic_video_parser.py +0 -52
- mapillary_tools/video_data_extraction/extractors/gopro_parser.py +0 -43
- mapillary_tools/video_data_extraction/extractors/gpx_parser.py +0 -108
- 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.13.3.dist-info/RECORD +0 -75
- /mapillary_tools/{geotag → gpmf}/gpmf_gps_filter.py +0 -0
- {mapillary_tools-0.13.3.dist-info → mapillary_tools-0.14.0.dist-info}/entry_points.txt +0 -0
- {mapillary_tools-0.13.3.dist-info → mapillary_tools-0.14.0.dist-info/licenses}/LICENSE +0 -0
- {mapillary_tools-0.13.3.dist-info → mapillary_tools-0.14.0.dist-info}/top_level.txt +0 -0
|
@@ -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,182 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import enum
|
|
5
|
+
import json
|
|
6
|
+
import typing as T
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import jsonschema
|
|
10
|
+
|
|
11
|
+
from .. import types
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SourceType(enum.Enum):
|
|
15
|
+
NATIVE = "native"
|
|
16
|
+
GPX = "gpx"
|
|
17
|
+
NMEA = "nmea"
|
|
18
|
+
EXIFTOOL_XML = "exiftool_xml"
|
|
19
|
+
EXIFTOOL_RUNTIME = "exiftool_runtime"
|
|
20
|
+
|
|
21
|
+
# Legacy source types for images
|
|
22
|
+
GOPRO = "gopro"
|
|
23
|
+
BLACKVUE = "blackvue"
|
|
24
|
+
CAMM = "camm"
|
|
25
|
+
EXIF = "exif"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
SOURCE_TYPE_ALIAS: dict[str, SourceType] = {
|
|
29
|
+
"blackvue_videos": SourceType.BLACKVUE,
|
|
30
|
+
"gopro_videos": SourceType.GOPRO,
|
|
31
|
+
"exiftool": SourceType.EXIFTOOL_RUNTIME,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclasses.dataclass
|
|
36
|
+
class SourceOption:
|
|
37
|
+
# Type of the source
|
|
38
|
+
source: SourceType
|
|
39
|
+
|
|
40
|
+
# Filter by these filetypes
|
|
41
|
+
filetypes: set[types.FileType] | None = None
|
|
42
|
+
|
|
43
|
+
num_processes: int | None = None
|
|
44
|
+
|
|
45
|
+
source_path: SourcePathOption | None = None
|
|
46
|
+
|
|
47
|
+
interpolation: InterpolationOption | None = None
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_dict(cls, data: dict[str, T.Any]) -> SourceOption:
|
|
51
|
+
validate_option(data)
|
|
52
|
+
|
|
53
|
+
kwargs: dict[str, T.Any] = {}
|
|
54
|
+
for k, v in data.items():
|
|
55
|
+
# None values are considered as absent and should be ignored
|
|
56
|
+
if v is None:
|
|
57
|
+
continue
|
|
58
|
+
if k == "source":
|
|
59
|
+
kwargs[k] = SourceType(SOURCE_TYPE_ALIAS.get(v, v))
|
|
60
|
+
elif k == "filetypes":
|
|
61
|
+
kwargs[k] = {types.FileType(t) for t in v}
|
|
62
|
+
elif k == "source_path":
|
|
63
|
+
kwargs.setdefault(
|
|
64
|
+
"source_path", SourcePathOption(source_path=Path(v))
|
|
65
|
+
).sourthe_path = Path(v)
|
|
66
|
+
elif k == "pattern":
|
|
67
|
+
kwargs.setdefault(
|
|
68
|
+
"source_path", SourcePathOption(pattern=v)
|
|
69
|
+
).pattern = v
|
|
70
|
+
elif k == "interpolation_offset_time":
|
|
71
|
+
kwargs.setdefault(
|
|
72
|
+
"interpolation", InterpolationOption()
|
|
73
|
+
).offset_time = v
|
|
74
|
+
elif k == "interpolation_use_gpx_start_time":
|
|
75
|
+
kwargs.setdefault(
|
|
76
|
+
"interpolation", InterpolationOption()
|
|
77
|
+
).use_gpx_start_time = v
|
|
78
|
+
|
|
79
|
+
return cls(**kwargs)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclasses.dataclass
|
|
83
|
+
class SourcePathOption:
|
|
84
|
+
pattern: str | None = None
|
|
85
|
+
source_path: Path | None = None
|
|
86
|
+
|
|
87
|
+
def __post_init__(self):
|
|
88
|
+
if self.source_path is None and self.pattern is None:
|
|
89
|
+
raise ValueError("Either pattern or source_path must be provided")
|
|
90
|
+
|
|
91
|
+
def resolve(self, path: Path) -> Path:
|
|
92
|
+
"""
|
|
93
|
+
Resolve the source path or pattern against the given path.
|
|
94
|
+
|
|
95
|
+
Examples:
|
|
96
|
+
>>> from pathlib import Path
|
|
97
|
+
>>> opt = SourcePathOption(source_path=Path("/foo/bar.mp4"))
|
|
98
|
+
>>> opt.resolve(Path("/baz/qux.mp4"))
|
|
99
|
+
PosixPath('/foo/bar.mp4')
|
|
100
|
+
|
|
101
|
+
>>> opt = SourcePathOption(pattern="videos/%g_sub%e")
|
|
102
|
+
>>> opt.resolve(Path("/data/video1.mp4"))
|
|
103
|
+
PosixPath('/data/videos/video1_sub.mp4')
|
|
104
|
+
|
|
105
|
+
>>> opt = SourcePathOption(pattern="/abs/path/%f")
|
|
106
|
+
>>> opt.resolve(Path("/tmp/abc.mov"))
|
|
107
|
+
PosixPath('/abs/path/abc.mov')
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
if self.source_path is not None:
|
|
111
|
+
return self.source_path
|
|
112
|
+
|
|
113
|
+
assert self.pattern is not None, (
|
|
114
|
+
"either pattern or source_path must be provided"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# %f: the full video filename (foo.mp4)
|
|
118
|
+
# %g: the video filename without extension (foo)
|
|
119
|
+
# %e: the video filename extension (.mp4)
|
|
120
|
+
replaced = Path(
|
|
121
|
+
self.pattern.replace("%f", path.name)
|
|
122
|
+
.replace("%g", path.stem)
|
|
123
|
+
.replace("%e", path.suffix)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
abs_path = (
|
|
127
|
+
replaced
|
|
128
|
+
if replaced.is_absolute()
|
|
129
|
+
else Path.joinpath(path.parent.resolve(), replaced)
|
|
130
|
+
).resolve()
|
|
131
|
+
|
|
132
|
+
return abs_path
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclasses.dataclass
|
|
136
|
+
class InterpolationOption:
|
|
137
|
+
offset_time: float = 0.0
|
|
138
|
+
use_gpx_start_time: bool = False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
SourceOptionSchema = {
|
|
142
|
+
"type": "object",
|
|
143
|
+
"properties": {
|
|
144
|
+
"source": {
|
|
145
|
+
"type": "string",
|
|
146
|
+
"enum": [s.value for s in SourceType] + list(SOURCE_TYPE_ALIAS.keys()),
|
|
147
|
+
},
|
|
148
|
+
"filetypes": {
|
|
149
|
+
"type": "array",
|
|
150
|
+
"items": {
|
|
151
|
+
"type": "string",
|
|
152
|
+
"enum": [t.value for t in types.FileType],
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
"source_path": {
|
|
156
|
+
"type": "string",
|
|
157
|
+
},
|
|
158
|
+
"pattern": {
|
|
159
|
+
"type": "string",
|
|
160
|
+
},
|
|
161
|
+
"num_processes": {
|
|
162
|
+
"type": "integer",
|
|
163
|
+
},
|
|
164
|
+
"interpolation_offset_time": {
|
|
165
|
+
"type": "number",
|
|
166
|
+
},
|
|
167
|
+
"interpolation_use_gpx_start_time": {
|
|
168
|
+
"type": "boolean",
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
"required": ["source"],
|
|
172
|
+
"additionalProperties": False,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def validate_option(instance):
|
|
177
|
+
jsonschema.validate(instance=instance, schema=SourceOptionSchema)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
if __name__ == "__main__":
|
|
181
|
+
# python -m mapillary_tools.geotag.options > schema/geotag_source_option.json
|
|
182
|
+
print(json.dumps(SourceOptionSchema, indent=4))
|
mapillary_tools/geotag/utils.py
CHANGED
|
@@ -1,26 +1,62 @@
|
|
|
1
|
-
import
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
2
4
|
import typing as T
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
from pathlib import Path
|
|
3
7
|
|
|
4
8
|
import gpxpy
|
|
5
|
-
import gpxpy.gpx
|
|
6
9
|
|
|
7
|
-
from .. import geo
|
|
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)
|
|
8
19
|
|
|
20
|
+
tracks: list[Track] = []
|
|
9
21
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
+
)
|
|
13
36
|
|
|
37
|
+
return tracks
|
|
14
38
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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()
|
|
24
59
|
)
|
|
25
60
|
)
|
|
26
|
-
|
|
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.VideoMetadata:
|
|
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.VideoMetadata:
|
|
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
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import enum
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
import typing as T
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
if sys.version_info >= (3, 12):
|
|
11
|
+
from typing import override
|
|
12
|
+
else:
|
|
13
|
+
from typing_extensions import override
|
|
14
|
+
|
|
15
|
+
from ... import exceptions, geo, telemetry, types
|
|
16
|
+
from ..utils import parse_gpx
|
|
17
|
+
from .base import BaseVideoExtractor
|
|
18
|
+
from .native import NativeVideoExtractor
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
LOG = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SyncMode(enum.Enum):
|
|
25
|
+
# Sync by video GPS timestamps if found, otherwise rebase
|
|
26
|
+
SYNC = "sync"
|
|
27
|
+
# Sync by video GPS timestamps, and throw if not found
|
|
28
|
+
STRICT_SYNC = "strict_sync"
|
|
29
|
+
# Rebase all GPX timestamps to start from 0
|
|
30
|
+
REBASE = "rebase"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class GPXVideoExtractor(BaseVideoExtractor):
|
|
34
|
+
def __init__(
|
|
35
|
+
self, video_path: Path, gpx_path: Path, sync_mode: SyncMode = SyncMode.SYNC
|
|
36
|
+
):
|
|
37
|
+
self.video_path = video_path
|
|
38
|
+
self.gpx_path = gpx_path
|
|
39
|
+
self.sync_mode = sync_mode
|
|
40
|
+
|
|
41
|
+
@override
|
|
42
|
+
def extract(self) -> types.VideoMetadata:
|
|
43
|
+
gpx_tracks = parse_gpx(self.gpx_path)
|
|
44
|
+
|
|
45
|
+
if 1 < len(gpx_tracks):
|
|
46
|
+
LOG.warning(
|
|
47
|
+
f"Found {len(gpx_tracks)} tracks in the GPX file {self.gpx_path}. Will merge points in all the tracks as a single track for interpolation"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
gpx_points: T.Sequence[geo.Point] = sum(gpx_tracks, [])
|
|
51
|
+
|
|
52
|
+
native_extractor = NativeVideoExtractor(self.video_path)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
native_video_metadata = native_extractor.extract()
|
|
56
|
+
except exceptions.MapillaryVideoGPSNotFoundError as ex:
|
|
57
|
+
if self.sync_mode is SyncMode.STRICT_SYNC:
|
|
58
|
+
raise ex
|
|
59
|
+
self._rebase_times(gpx_points)
|
|
60
|
+
return types.VideoMetadata(
|
|
61
|
+
filename=self.video_path,
|
|
62
|
+
filetype=types.FileType.VIDEO,
|
|
63
|
+
points=gpx_points,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if self.sync_mode is SyncMode.REBASE:
|
|
67
|
+
self._rebase_times(gpx_points)
|
|
68
|
+
else:
|
|
69
|
+
offset = self._gpx_offset(gpx_points, native_video_metadata.points)
|
|
70
|
+
self._rebase_times(gpx_points, offset=offset)
|
|
71
|
+
|
|
72
|
+
return dataclasses.replace(native_video_metadata, points=gpx_points)
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def _rebase_times(cls, points: T.Sequence[geo.Point], offset: float = 0.0) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Rebase point times to start from **offset**
|
|
78
|
+
"""
|
|
79
|
+
if points:
|
|
80
|
+
first_timestamp = points[0].time
|
|
81
|
+
for p in points:
|
|
82
|
+
p.time = (p.time - first_timestamp) + offset
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def _gpx_offset(
|
|
86
|
+
cls, gpx_points: T.Sequence[geo.Point], video_gps_points: T.Sequence[geo.Point]
|
|
87
|
+
) -> float:
|
|
88
|
+
"""
|
|
89
|
+
Calculate the offset that needs to be applied to the GPX points to sync with the video GPS points.
|
|
90
|
+
|
|
91
|
+
>>> gpx_points = [geo.Point(time=5, lat=1, lon=1, alt=None, angle=None)]
|
|
92
|
+
>>> GPXVideoExtractor._gpx_offset(gpx_points, gpx_points)
|
|
93
|
+
0.0
|
|
94
|
+
>>> GPXVideoExtractor._gpx_offset(gpx_points, [])
|
|
95
|
+
0.0
|
|
96
|
+
>>> GPXVideoExtractor._gpx_offset([], gpx_points)
|
|
97
|
+
0.0
|
|
98
|
+
"""
|
|
99
|
+
offset: float = 0.0
|
|
100
|
+
|
|
101
|
+
if not gpx_points or not video_gps_points:
|
|
102
|
+
return offset
|
|
103
|
+
|
|
104
|
+
gps_epoch_time: float | None = None
|
|
105
|
+
gps_point = video_gps_points[0]
|
|
106
|
+
if isinstance(gps_point, telemetry.GPSPoint):
|
|
107
|
+
if gps_point.epoch_time is not None:
|
|
108
|
+
gps_epoch_time = gps_point.epoch_time
|
|
109
|
+
elif isinstance(gps_point, telemetry.CAMMGPSPoint):
|
|
110
|
+
if gps_point.time_gps_epoch is not None:
|
|
111
|
+
gps_epoch_time = gps_point.time_gps_epoch
|
|
112
|
+
|
|
113
|
+
if gps_epoch_time is not None:
|
|
114
|
+
offset = gpx_points[0].time - gps_epoch_time
|
|
115
|
+
|
|
116
|
+
return offset
|
|
@@ -0,0 +1,160 @@
|
|
|
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 ...mp4 import construct_mp4_parser, simple_mp4_parser
|
|
16
|
+
from .base import BaseVideoExtractor
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GoProVideoExtractor(BaseVideoExtractor):
|
|
20
|
+
@override
|
|
21
|
+
def extract(self) -> types.VideoMetadata:
|
|
22
|
+
with self.video_path.open("rb") as fp:
|
|
23
|
+
gopro_info = gpmf_parser.extract_gopro_info(fp)
|
|
24
|
+
|
|
25
|
+
if gopro_info is None:
|
|
26
|
+
raise exceptions.MapillaryVideoGPSNotFoundError(
|
|
27
|
+
"No GPS data found from the video"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
gps_points = gopro_info.gps
|
|
31
|
+
assert gps_points is not None, "must have GPS data extracted"
|
|
32
|
+
if not gps_points:
|
|
33
|
+
raise exceptions.MapillaryGPXEmptyError("Empty GPS data found")
|
|
34
|
+
|
|
35
|
+
gps_points = T.cast(
|
|
36
|
+
T.List[telemetry.GPSPoint], gpmf_gps_filter.remove_noisy_points(gps_points)
|
|
37
|
+
)
|
|
38
|
+
if not gps_points:
|
|
39
|
+
raise exceptions.MapillaryGPSNoiseError("GPS is too noisy")
|
|
40
|
+
|
|
41
|
+
video_metadata = types.VideoMetadata(
|
|
42
|
+
filename=self.video_path,
|
|
43
|
+
filesize=utils.get_file_size(self.video_path),
|
|
44
|
+
filetype=types.FileType.GOPRO,
|
|
45
|
+
points=T.cast(T.List[geo.Point], gps_points),
|
|
46
|
+
make=gopro_info.make,
|
|
47
|
+
model=gopro_info.model,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return video_metadata
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CAMMVideoExtractor(BaseVideoExtractor):
|
|
54
|
+
@override
|
|
55
|
+
def extract(self) -> types.VideoMetadata:
|
|
56
|
+
with self.video_path.open("rb") as fp:
|
|
57
|
+
camm_info = camm_parser.extract_camm_info(fp)
|
|
58
|
+
|
|
59
|
+
if camm_info is None:
|
|
60
|
+
raise exceptions.MapillaryVideoGPSNotFoundError(
|
|
61
|
+
"No GPS data found from the video"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if not camm_info.gps and not camm_info.mini_gps:
|
|
65
|
+
raise exceptions.MapillaryGPXEmptyError("Empty GPS data found")
|
|
66
|
+
|
|
67
|
+
return types.VideoMetadata(
|
|
68
|
+
filename=self.video_path,
|
|
69
|
+
filesize=utils.get_file_size(self.video_path),
|
|
70
|
+
filetype=types.FileType.CAMM,
|
|
71
|
+
points=T.cast(T.List[geo.Point], camm_info.gps or camm_info.mini_gps),
|
|
72
|
+
make=camm_info.make,
|
|
73
|
+
model=camm_info.model,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class BlackVueVideoExtractor(BaseVideoExtractor):
|
|
78
|
+
@override
|
|
79
|
+
def extract(self) -> types.VideoMetadata:
|
|
80
|
+
with self.video_path.open("rb") as fp:
|
|
81
|
+
blackvue_info = blackvue_parser.extract_blackvue_info(fp)
|
|
82
|
+
|
|
83
|
+
if blackvue_info is None:
|
|
84
|
+
raise exceptions.MapillaryVideoGPSNotFoundError(
|
|
85
|
+
"No GPS data found from the video"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if not blackvue_info.gps:
|
|
89
|
+
raise exceptions.MapillaryGPXEmptyError("Empty GPS data found")
|
|
90
|
+
|
|
91
|
+
video_metadata = types.VideoMetadata(
|
|
92
|
+
filename=self.video_path,
|
|
93
|
+
filesize=utils.get_file_size(self.video_path),
|
|
94
|
+
filetype=types.FileType.BLACKVUE,
|
|
95
|
+
points=blackvue_info.gps,
|
|
96
|
+
make=blackvue_info.make,
|
|
97
|
+
model=blackvue_info.model,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return video_metadata
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class NativeVideoExtractor(BaseVideoExtractor):
|
|
104
|
+
def __init__(self, video_path: Path, filetypes: set[types.FileType] | None = None):
|
|
105
|
+
super().__init__(video_path)
|
|
106
|
+
self.filetypes = filetypes
|
|
107
|
+
|
|
108
|
+
@override
|
|
109
|
+
def extract(self) -> types.VideoMetadata:
|
|
110
|
+
ft = self.filetypes
|
|
111
|
+
extractor: BaseVideoExtractor
|
|
112
|
+
|
|
113
|
+
if ft is None or types.FileType.VIDEO in ft or types.FileType.GOPRO in ft:
|
|
114
|
+
extractor = GoProVideoExtractor(self.video_path)
|
|
115
|
+
try:
|
|
116
|
+
return extractor.extract()
|
|
117
|
+
except simple_mp4_parser.BoxNotFoundError as ex:
|
|
118
|
+
raise exceptions.MapillaryInvalidVideoError(
|
|
119
|
+
f"Invalid video: {ex}"
|
|
120
|
+
) from ex
|
|
121
|
+
except construct_mp4_parser.BoxNotFoundError as ex:
|
|
122
|
+
raise exceptions.MapillaryInvalidVideoError(
|
|
123
|
+
f"Invalid video: {ex}"
|
|
124
|
+
) from ex
|
|
125
|
+
except exceptions.MapillaryVideoGPSNotFoundError:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
if ft is None or types.FileType.VIDEO in ft or types.FileType.CAMM in ft:
|
|
129
|
+
extractor = CAMMVideoExtractor(self.video_path)
|
|
130
|
+
try:
|
|
131
|
+
return extractor.extract()
|
|
132
|
+
except simple_mp4_parser.BoxNotFoundError as ex:
|
|
133
|
+
raise exceptions.MapillaryInvalidVideoError(
|
|
134
|
+
f"Invalid video: {ex}"
|
|
135
|
+
) from ex
|
|
136
|
+
except construct_mp4_parser.BoxNotFoundError as ex:
|
|
137
|
+
raise exceptions.MapillaryInvalidVideoError(
|
|
138
|
+
f"Invalid video: {ex}"
|
|
139
|
+
) from ex
|
|
140
|
+
except exceptions.MapillaryVideoGPSNotFoundError:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
if ft is None or types.FileType.VIDEO in ft or types.FileType.BLACKVUE in ft:
|
|
144
|
+
extractor = BlackVueVideoExtractor(self.video_path)
|
|
145
|
+
try:
|
|
146
|
+
return extractor.extract()
|
|
147
|
+
except simple_mp4_parser.BoxNotFoundError as ex:
|
|
148
|
+
raise exceptions.MapillaryInvalidVideoError(
|
|
149
|
+
f"Invalid video: {ex}"
|
|
150
|
+
) from ex
|
|
151
|
+
except construct_mp4_parser.BoxNotFoundError as ex:
|
|
152
|
+
raise exceptions.MapillaryInvalidVideoError(
|
|
153
|
+
f"Invalid video: {ex}"
|
|
154
|
+
) from ex
|
|
155
|
+
except exceptions.MapillaryVideoGPSNotFoundError:
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
raise exceptions.MapillaryVideoGPSNotFoundError(
|
|
159
|
+
"No GPS data found from the video"
|
|
160
|
+
)
|