mapillary-tools 0.14.0a1__py3-none-any.whl → 0.14.0b1__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 +5 -4
- mapillary_tools/authenticate.py +9 -9
- mapillary_tools/blackvue_parser.py +79 -22
- mapillary_tools/camm/camm_parser.py +5 -5
- mapillary_tools/commands/__main__.py +1 -2
- mapillary_tools/config.py +41 -18
- mapillary_tools/constants.py +3 -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 +88 -49
- mapillary_tools/exiftool_runner.py +4 -24
- mapillary_tools/ffmpeg.py +417 -242
- mapillary_tools/geo.py +4 -21
- mapillary_tools/geotag/__init__.py +0 -1
- mapillary_tools/geotag/{geotag_from_generic.py → base.py} +34 -50
- mapillary_tools/geotag/factory.py +105 -103
- mapillary_tools/geotag/geotag_images_from_exif.py +15 -51
- mapillary_tools/geotag/geotag_images_from_exiftool.py +118 -63
- mapillary_tools/geotag/geotag_images_from_gpx.py +33 -16
- 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 +51 -14
- mapillary_tools/geotag/geotag_videos_from_exiftool.py +123 -0
- mapillary_tools/geotag/geotag_videos_from_gpx.py +35 -123
- 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 +26 -3
- 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/geotag/video_extractors/gpx.py +116 -0
- mapillary_tools/geotag/video_extractors/native.py +135 -0
- mapillary_tools/gpmf/gpmf_parser.py +16 -16
- mapillary_tools/gpmf/gps_filter.py +5 -3
- mapillary_tools/history.py +8 -3
- 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 +21 -15
- mapillary_tools/process_sequence_properties.py +49 -49
- mapillary_tools/sample_video.py +15 -14
- mapillary_tools/serializer/description.py +587 -0
- mapillary_tools/serializer/gpx.py +132 -0
- mapillary_tools/telemetry.py +6 -5
- mapillary_tools/types.py +64 -635
- mapillary_tools/upload.py +176 -197
- mapillary_tools/upload_api_v4.py +94 -51
- mapillary_tools/uploader.py +284 -138
- mapillary_tools/utils.py +16 -18
- {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0b1.dist-info}/METADATA +87 -31
- mapillary_tools-0.14.0b1.dist-info/RECORD +75 -0
- {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0b1.dist-info}/WHEEL +1 -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/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.14.0a1.dist-info/RECORD +0 -78
- {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0b1.dist-info}/entry_points.txt +0 -0
- {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0b1.dist-info}/licenses/LICENSE +0 -0
- {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0b1.dist-info}/top_level.txt +0 -0
|
@@ -1,140 +1,52 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import dataclasses
|
|
4
|
-
import datetime
|
|
5
3
|
import logging
|
|
6
|
-
|
|
4
|
+
import sys
|
|
7
5
|
import typing as T
|
|
8
6
|
from pathlib import Path
|
|
9
7
|
|
|
10
|
-
|
|
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, types
|
|
11
14
|
from . import options
|
|
12
|
-
from .
|
|
13
|
-
from .
|
|
14
|
-
from .geotag_videos_from_video import NativeVideoExtractor
|
|
15
|
+
from .base import GeotagVideosFromGeneric
|
|
16
|
+
from .video_extractors.gpx import GPXVideoExtractor
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
LOG = logging.getLogger(__name__)
|
|
18
20
|
|
|
19
21
|
|
|
20
|
-
class GPXVideoExtractor(GenericVideoExtractor):
|
|
21
|
-
def __init__(self, video_path: Path, gpx_path: Path):
|
|
22
|
-
self.video_path = video_path
|
|
23
|
-
self.gpx_path = gpx_path
|
|
24
|
-
|
|
25
|
-
def extract(self) -> types.VideoMetadataOrError:
|
|
26
|
-
try:
|
|
27
|
-
gpx_tracks = parse_gpx(self.gpx_path)
|
|
28
|
-
except Exception as ex:
|
|
29
|
-
raise RuntimeError(
|
|
30
|
-
f"Error parsing GPX {self.gpx_path}: {ex.__class__.__name__}: {ex}"
|
|
31
|
-
)
|
|
32
|
-
|
|
33
|
-
if 1 < len(gpx_tracks):
|
|
34
|
-
LOG.warning(
|
|
35
|
-
"Found %s tracks in the GPX file %s. Will merge points in all the tracks as a single track for interpolation",
|
|
36
|
-
len(gpx_tracks),
|
|
37
|
-
self.gpx_path,
|
|
38
|
-
)
|
|
39
|
-
|
|
40
|
-
gpx_points: T.Sequence[geo.Point] = sum(gpx_tracks, [])
|
|
41
|
-
|
|
42
|
-
native_extractor = NativeVideoExtractor(self.video_path)
|
|
43
|
-
|
|
44
|
-
video_metadata_or_error = native_extractor.extract()
|
|
45
|
-
|
|
46
|
-
if isinstance(video_metadata_or_error, types.ErrorMetadata):
|
|
47
|
-
self._rebase_times(gpx_points)
|
|
48
|
-
return types.VideoMetadata(
|
|
49
|
-
filename=video_metadata_or_error.filename,
|
|
50
|
-
filetype=video_metadata_or_error.filetype or types.FileType.VIDEO,
|
|
51
|
-
points=gpx_points,
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
video_metadata = video_metadata_or_error
|
|
55
|
-
|
|
56
|
-
offset = self._synx_gpx_by_first_gps_timestamp(
|
|
57
|
-
gpx_points, video_metadata.points
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
self._rebase_times(gpx_points, offset=offset)
|
|
61
|
-
|
|
62
|
-
return dataclasses.replace(video_metadata_or_error, points=gpx_points)
|
|
63
|
-
|
|
64
|
-
@staticmethod
|
|
65
|
-
def _rebase_times(points: T.Sequence[geo.Point], offset: float = 0.0):
|
|
66
|
-
"""
|
|
67
|
-
Make point times start from 0
|
|
68
|
-
"""
|
|
69
|
-
if points:
|
|
70
|
-
first_timestamp = points[0].time
|
|
71
|
-
for p in points:
|
|
72
|
-
p.time = (p.time - first_timestamp) + offset
|
|
73
|
-
return points
|
|
74
|
-
|
|
75
|
-
def _synx_gpx_by_first_gps_timestamp(
|
|
76
|
-
self, gpx_points: T.Sequence[geo.Point], video_gps_points: T.Sequence[geo.Point]
|
|
77
|
-
) -> float:
|
|
78
|
-
offset: float = 0.0
|
|
79
|
-
|
|
80
|
-
if not gpx_points:
|
|
81
|
-
return offset
|
|
82
|
-
|
|
83
|
-
first_gpx_dt = datetime.datetime.fromtimestamp(
|
|
84
|
-
gpx_points[0].time, tz=datetime.timezone.utc
|
|
85
|
-
)
|
|
86
|
-
LOG.info("First GPX timestamp: %s", first_gpx_dt)
|
|
87
|
-
|
|
88
|
-
if not video_gps_points:
|
|
89
|
-
LOG.warning(
|
|
90
|
-
"Skip GPX synchronization because no GPS found in video %s",
|
|
91
|
-
self.video_path,
|
|
92
|
-
)
|
|
93
|
-
return offset
|
|
94
|
-
|
|
95
|
-
first_gps_point = video_gps_points[0]
|
|
96
|
-
if isinstance(first_gps_point, telemetry.GPSPoint):
|
|
97
|
-
if first_gps_point.epoch_time is not None:
|
|
98
|
-
first_gps_dt = datetime.datetime.fromtimestamp(
|
|
99
|
-
first_gps_point.epoch_time, tz=datetime.timezone.utc
|
|
100
|
-
)
|
|
101
|
-
LOG.info("First GPS timestamp: %s", first_gps_dt)
|
|
102
|
-
offset = gpx_points[0].time - first_gps_point.epoch_time
|
|
103
|
-
if offset:
|
|
104
|
-
LOG.warning(
|
|
105
|
-
"Found offset between GPX %s and video GPS timestamps %s: %s seconds",
|
|
106
|
-
first_gpx_dt,
|
|
107
|
-
first_gps_dt,
|
|
108
|
-
offset,
|
|
109
|
-
)
|
|
110
|
-
else:
|
|
111
|
-
LOG.info(
|
|
112
|
-
"GPX and GPS are perfectly synchronized (all starts from %s)",
|
|
113
|
-
first_gpx_dt,
|
|
114
|
-
)
|
|
115
|
-
else:
|
|
116
|
-
LOG.warning(
|
|
117
|
-
"Skip GPX synchronization because no GPS epoch time found in video %s",
|
|
118
|
-
self.video_path,
|
|
119
|
-
)
|
|
120
|
-
|
|
121
|
-
return offset
|
|
122
|
-
|
|
123
|
-
|
|
124
22
|
class GeotagVideosFromGPX(GeotagVideosFromGeneric):
|
|
125
23
|
def __init__(
|
|
126
24
|
self,
|
|
127
|
-
|
|
128
|
-
option: options.SourcePathOption | None = None,
|
|
25
|
+
source_path: options.SourcePathOption | None = None,
|
|
129
26
|
num_processes: int | None = None,
|
|
130
27
|
):
|
|
131
|
-
super().__init__(
|
|
132
|
-
if
|
|
133
|
-
|
|
134
|
-
self.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
]
|
|
28
|
+
super().__init__(num_processes=num_processes)
|
|
29
|
+
if source_path is None:
|
|
30
|
+
source_path = options.SourcePathOption(pattern="%g.gpx")
|
|
31
|
+
self.source_path = source_path
|
|
32
|
+
|
|
33
|
+
@override
|
|
34
|
+
def _generate_video_extractors(
|
|
35
|
+
self, video_paths: T.Sequence[Path]
|
|
36
|
+
) -> T.Sequence[GPXVideoExtractor | types.ErrorMetadata]:
|
|
37
|
+
results: list[GPXVideoExtractor | types.ErrorMetadata] = []
|
|
38
|
+
for video_path in video_paths:
|
|
39
|
+
source_path = self.source_path.resolve(video_path)
|
|
40
|
+
if source_path.is_file():
|
|
41
|
+
results.append(GPXVideoExtractor(video_path, source_path))
|
|
42
|
+
else:
|
|
43
|
+
results.append(
|
|
44
|
+
types.describe_error_metadata(
|
|
45
|
+
exceptions.MapillaryVideoGPSNotFoundError(
|
|
46
|
+
"GPX file not found for video"
|
|
47
|
+
),
|
|
48
|
+
filename=video_path,
|
|
49
|
+
filetype=types.FileType.VIDEO,
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
return results
|
|
@@ -1,165 +1,32 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import sys
|
|
3
4
|
import typing as T
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
from
|
|
8
|
-
|
|
9
|
-
from
|
|
10
|
-
from .geotag_from_generic import GenericVideoExtractor, GeotagVideosFromGeneric
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class GoProVideoExtractor(GenericVideoExtractor):
|
|
14
|
-
def extract(self) -> types.VideoMetadataOrError:
|
|
15
|
-
with self.video_path.open("rb") as fp:
|
|
16
|
-
gopro_info = gpmf_parser.extract_gopro_info(fp)
|
|
17
|
-
|
|
18
|
-
if gopro_info is None:
|
|
19
|
-
raise exceptions.MapillaryVideoGPSNotFoundError(
|
|
20
|
-
"No GPS data found from the video"
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
gps_points = gopro_info.gps
|
|
24
|
-
assert gps_points is not None, "must have GPS data extracted"
|
|
25
|
-
if not gps_points:
|
|
26
|
-
# Instead of raising an exception, return error metadata to tell the file type
|
|
27
|
-
ex: exceptions.MapillaryDescriptionError = (
|
|
28
|
-
exceptions.MapillaryGPXEmptyError("Empty GPS data found")
|
|
29
|
-
)
|
|
30
|
-
return types.describe_error_metadata(
|
|
31
|
-
ex, self.video_path, filetype=FileType.GOPRO
|
|
32
|
-
)
|
|
33
|
-
|
|
34
|
-
gps_points = T.cast(
|
|
35
|
-
T.List[telemetry.GPSPoint], gpmf_gps_filter.remove_noisy_points(gps_points)
|
|
36
|
-
)
|
|
37
|
-
if not gps_points:
|
|
38
|
-
# Instead of raising an exception, return error metadata to tell the file type
|
|
39
|
-
ex = exceptions.MapillaryGPSNoiseError("GPS is too noisy")
|
|
40
|
-
return types.describe_error_metadata(
|
|
41
|
-
ex, self.video_path, filetype=FileType.GOPRO
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
video_metadata = types.VideoMetadata(
|
|
45
|
-
filename=self.video_path,
|
|
46
|
-
filesize=utils.get_file_size(self.video_path),
|
|
47
|
-
filetype=FileType.GOPRO,
|
|
48
|
-
points=T.cast(T.List[geo.Point], gps_points),
|
|
49
|
-
make=gopro_info.make,
|
|
50
|
-
model=gopro_info.model,
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
return video_metadata
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
class CAMMVideoExtractor(GenericVideoExtractor):
|
|
57
|
-
def extract(self) -> types.VideoMetadataOrError:
|
|
58
|
-
with self.video_path.open("rb") as fp:
|
|
59
|
-
camm_info = camm_parser.extract_camm_info(fp)
|
|
60
|
-
|
|
61
|
-
if camm_info is None:
|
|
62
|
-
raise exceptions.MapillaryVideoGPSNotFoundError(
|
|
63
|
-
"No GPS data found from the video"
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
if not camm_info.gps and not camm_info.mini_gps:
|
|
67
|
-
# Instead of raising an exception, return error metadata to tell the file type
|
|
68
|
-
ex: exceptions.MapillaryDescriptionError = (
|
|
69
|
-
exceptions.MapillaryGPXEmptyError("Empty GPS data found")
|
|
70
|
-
)
|
|
71
|
-
return types.describe_error_metadata(
|
|
72
|
-
ex, self.video_path, filetype=FileType.CAMM
|
|
73
|
-
)
|
|
74
|
-
|
|
75
|
-
return types.VideoMetadata(
|
|
76
|
-
filename=self.video_path,
|
|
77
|
-
filesize=utils.get_file_size(self.video_path),
|
|
78
|
-
filetype=FileType.CAMM,
|
|
79
|
-
points=T.cast(T.List[geo.Point], camm_info.gps or camm_info.mini_gps),
|
|
80
|
-
make=camm_info.make,
|
|
81
|
-
model=camm_info.model,
|
|
82
|
-
)
|
|
83
|
-
|
|
7
|
+
if sys.version_info >= (3, 12):
|
|
8
|
+
from typing import override
|
|
9
|
+
else:
|
|
10
|
+
from typing_extensions import override
|
|
84
11
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
blackvue_info = blackvue_parser.extract_blackvue_info(fp)
|
|
89
|
-
|
|
90
|
-
if blackvue_info is None:
|
|
91
|
-
raise exceptions.MapillaryVideoGPSNotFoundError(
|
|
92
|
-
"No GPS data found from the video"
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
if not blackvue_info.gps:
|
|
96
|
-
# Instead of raising an exception, return error metadata to tell the file type
|
|
97
|
-
ex: exceptions.MapillaryDescriptionError = (
|
|
98
|
-
exceptions.MapillaryGPXEmptyError("Empty GPS data found")
|
|
99
|
-
)
|
|
100
|
-
return types.describe_error_metadata(
|
|
101
|
-
ex, self.video_path, filetype=FileType.BLACKVUE
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
video_metadata = types.VideoMetadata(
|
|
105
|
-
filename=self.video_path,
|
|
106
|
-
filesize=utils.get_file_size(self.video_path),
|
|
107
|
-
filetype=FileType.BLACKVUE,
|
|
108
|
-
points=blackvue_info.gps or [],
|
|
109
|
-
make=blackvue_info.make,
|
|
110
|
-
model=blackvue_info.model,
|
|
111
|
-
)
|
|
112
|
-
|
|
113
|
-
return video_metadata
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
class NativeVideoExtractor(GenericVideoExtractor):
|
|
117
|
-
def __init__(self, video_path: Path, filetypes: set[FileType] | None = None):
|
|
118
|
-
super().__init__(video_path)
|
|
119
|
-
self.filetypes = filetypes
|
|
120
|
-
|
|
121
|
-
def extract(self) -> types.VideoMetadataOrError:
|
|
122
|
-
ft = self.filetypes
|
|
123
|
-
extractor: GenericVideoExtractor
|
|
124
|
-
|
|
125
|
-
if ft is None or FileType.VIDEO in ft or FileType.GOPRO in ft:
|
|
126
|
-
extractor = GoProVideoExtractor(self.video_path)
|
|
127
|
-
try:
|
|
128
|
-
return extractor.extract()
|
|
129
|
-
except exceptions.MapillaryVideoGPSNotFoundError:
|
|
130
|
-
pass
|
|
131
|
-
|
|
132
|
-
if ft is None or FileType.VIDEO in ft or FileType.CAMM in ft:
|
|
133
|
-
extractor = CAMMVideoExtractor(self.video_path)
|
|
134
|
-
try:
|
|
135
|
-
return extractor.extract()
|
|
136
|
-
except exceptions.MapillaryVideoGPSNotFoundError:
|
|
137
|
-
pass
|
|
138
|
-
|
|
139
|
-
if ft is None or FileType.VIDEO in ft or FileType.BLACKVUE in ft:
|
|
140
|
-
extractor = BlackVueVideoExtractor(self.video_path)
|
|
141
|
-
try:
|
|
142
|
-
return extractor.extract()
|
|
143
|
-
except exceptions.MapillaryVideoGPSNotFoundError:
|
|
144
|
-
pass
|
|
145
|
-
|
|
146
|
-
raise exceptions.MapillaryVideoGPSNotFoundError(
|
|
147
|
-
"No GPS data found from the video"
|
|
148
|
-
)
|
|
12
|
+
from ..types import FileType
|
|
13
|
+
from .base import GeotagVideosFromGeneric
|
|
14
|
+
from .video_extractors.native import NativeVideoExtractor
|
|
149
15
|
|
|
150
16
|
|
|
151
17
|
class GeotagVideosFromVideo(GeotagVideosFromGeneric):
|
|
152
18
|
def __init__(
|
|
153
19
|
self,
|
|
154
|
-
video_paths: T.Sequence[Path],
|
|
155
20
|
filetypes: set[FileType] | None = None,
|
|
156
21
|
num_processes: int | None = None,
|
|
157
22
|
):
|
|
158
|
-
super().__init__(
|
|
23
|
+
super().__init__(num_processes=num_processes)
|
|
159
24
|
self.filetypes = filetypes
|
|
160
25
|
|
|
161
|
-
|
|
26
|
+
@override
|
|
27
|
+
def _generate_video_extractors(
|
|
28
|
+
self, video_paths: T.Sequence[Path]
|
|
29
|
+
) -> T.Sequence[NativeVideoExtractor]:
|
|
162
30
|
return [
|
|
163
|
-
NativeVideoExtractor(path, filetypes=self.filetypes)
|
|
164
|
-
for path in self.video_paths
|
|
31
|
+
NativeVideoExtractor(path, filetypes=self.filetypes) for path in video_paths
|
|
165
32
|
]
|
|
@@ -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))
|
|
@@ -28,6 +28,7 @@ class SourceType(enum.Enum):
|
|
|
28
28
|
SOURCE_TYPE_ALIAS: dict[str, SourceType] = {
|
|
29
29
|
"blackvue_videos": SourceType.BLACKVUE,
|
|
30
30
|
"gopro_videos": SourceType.GOPRO,
|
|
31
|
+
"exiftool": SourceType.EXIFTOOL_RUNTIME,
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
|
|
@@ -59,9 +60,13 @@ class SourceOption:
|
|
|
59
60
|
elif k == "filetypes":
|
|
60
61
|
kwargs[k] = {types.FileType(t) for t in v}
|
|
61
62
|
elif k == "source_path":
|
|
62
|
-
kwargs.setdefault(
|
|
63
|
+
kwargs.setdefault(
|
|
64
|
+
"source_path", SourcePathOption(source_path=Path(v))
|
|
65
|
+
).sourthe_path = Path(v)
|
|
63
66
|
elif k == "pattern":
|
|
64
|
-
kwargs.setdefault(
|
|
67
|
+
kwargs.setdefault(
|
|
68
|
+
"source_path", SourcePathOption(pattern=v)
|
|
69
|
+
).pattern = v
|
|
65
70
|
elif k == "interpolation_offset_time":
|
|
66
71
|
kwargs.setdefault(
|
|
67
72
|
"interpolation", InterpolationOption()
|
|
@@ -84,6 +89,24 @@ class SourcePathOption:
|
|
|
84
89
|
raise ValueError("Either pattern or source_path must be provided")
|
|
85
90
|
|
|
86
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
|
+
|
|
87
110
|
if self.source_path is not None:
|
|
88
111
|
return self.source_path
|
|
89
112
|
|
|
@@ -139,7 +162,7 @@ SourceOptionSchema = {
|
|
|
139
162
|
"type": "integer",
|
|
140
163
|
},
|
|
141
164
|
"interpolation_offset_time": {
|
|
142
|
-
"type": "
|
|
165
|
+
"type": "number",
|
|
143
166
|
},
|
|
144
167
|
"interpolation_use_gpx_start_time": {
|
|
145
168
|
"type": "boolean",
|
|
@@ -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.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
|